Compare commits
685 Commits
irbekrm/fi
...
bradfitz/a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c084e3f6ec | ||
|
|
9f33aeb649 | ||
|
|
48343ee673 | ||
|
|
810da91a9e | ||
|
|
d62baa45e6 | ||
|
|
bb3d0cae5f | ||
|
|
00517c8189 | ||
|
|
da70a84a4b | ||
|
|
93db503565 | ||
|
|
c2a7f17f2b | ||
|
|
5cae7c51bf | ||
|
|
f1e1048977 | ||
|
|
3b93fd9c44 | ||
|
|
aefbed323f | ||
|
|
1355f622be | ||
|
|
c3c4c05331 | ||
|
|
8fd471ce57 | ||
|
|
e73cfd9700 | ||
|
|
f593d3c5c0 | ||
|
|
bfe5cd8760 | ||
|
|
0c9ade46a4 | ||
|
|
4474dcea68 | ||
|
|
0cfa217f3e | ||
|
|
1847f26042 | ||
|
|
7c6562c861 | ||
|
|
0c6bd9a33b | ||
|
|
cf41cec5a8 | ||
|
|
e38522c081 | ||
|
|
d8a3683fdf | ||
|
|
4e0fc037e6 | ||
|
|
00be1761b7 | ||
|
|
b9ecc50ce3 | ||
|
|
6ff85846bc | ||
|
|
64d70fb718 | ||
|
|
020cacbe70 | ||
|
|
c3306bfd15 | ||
|
|
23880eb5b0 | ||
|
|
2c8859c2e7 | ||
|
|
3090461961 | ||
|
|
8ba9b558d2 | ||
|
|
8dcbd988f7 | ||
|
|
065825e94c | ||
|
|
01185e436f | ||
|
|
809a6eba80 | ||
|
|
d4222fae95 | ||
|
|
45da3a4b28 | ||
|
|
43138c7a5c | ||
|
|
b0626ff84c | ||
|
|
634cc2ba4a | ||
|
|
d09e9d967f | ||
|
|
0ffc7bf38b | ||
|
|
49de23cf1b | ||
|
|
84c8860472 | ||
|
|
ddbc950f46 | ||
|
|
6985369479 | ||
|
|
3477bfd234 | ||
|
|
3f626c0d77 | ||
|
|
45354dab9b | ||
|
|
b4f46c31bb | ||
|
|
532b26145a | ||
|
|
e1e22785b4 | ||
|
|
f81348a16b | ||
|
|
540e4c83d0 | ||
|
|
2a2228f97b | ||
|
|
2cc1100d24 | ||
|
|
2336c340c4 | ||
|
|
1103044598 | ||
|
|
856ea2376b | ||
|
|
aecb0ab76b | ||
|
|
0f9a054cba | ||
|
|
9545e36007 | ||
|
|
38af62c7b3 | ||
|
|
11e96760ff | ||
|
|
94fa6d97c5 | ||
|
|
0d76d7d21c | ||
|
|
c0a1ed86cb | ||
|
|
41aac26106 | ||
|
|
5d07c17b93 | ||
|
|
9d1348fe21 | ||
|
|
853fe3b713 | ||
|
|
6ab39b7bcd | ||
|
|
e815ae0ec4 | ||
|
|
7fe6e50858 | ||
|
|
212270463b | ||
|
|
b2665d9b89 | ||
|
|
ae5bc88ebe | ||
|
|
85241f8408 | ||
|
|
d4d21a0bbf | ||
|
|
0f4c9c0ecb | ||
|
|
f8f53bb6d4 | ||
|
|
72587ab03c | ||
|
|
c76a6e5167 | ||
|
|
fd77965f23 | ||
|
|
e711ee5d22 | ||
|
|
877fa504b4 | ||
|
|
874db2173b | ||
|
|
bb60da2764 | ||
|
|
18fc093c0d | ||
|
|
c0a9895748 | ||
|
|
fa95318a47 | ||
|
|
22c89fcb19 | ||
|
|
d32d742af0 | ||
|
|
6a885dbc36 | ||
|
|
74dd24ce71 | ||
|
|
ff5f233c3a | ||
|
|
2aa9125ac4 | ||
|
|
5f22f72636 | ||
|
|
a8f9c0d6e4 | ||
|
|
e0d711c478 | ||
|
|
40c991f6b8 | ||
|
|
adc8368964 | ||
|
|
12e6094d9c | ||
|
|
ecc8035f73 | ||
|
|
f07ff47922 | ||
|
|
c2144c44a3 | ||
|
|
e7545f2eac | ||
|
|
17335d2104 | ||
|
|
f9949cde8b | ||
|
|
33029d4486 | ||
|
|
acb4a22dcc | ||
|
|
508980603b | ||
|
|
91f58c5e63 | ||
|
|
1938685d39 | ||
|
|
db1519cc9f | ||
|
|
2531065d10 | ||
|
|
fb420be176 | ||
|
|
367fba8520 | ||
|
|
52ef27ab7c | ||
|
|
5b7303817e | ||
|
|
c763b7a7db | ||
|
|
2cadb80fb2 | ||
|
|
910b4e8e6a | ||
|
|
89ee6bbdae | ||
|
|
94c79659fa | ||
|
|
f6d4d03355 | ||
|
|
60011e73b8 | ||
|
|
da40609abd | ||
|
|
29cf59a9b4 | ||
|
|
07c157ee9f | ||
|
|
83efadee9f | ||
|
|
841eaacb07 | ||
|
|
861dc3631c | ||
|
|
8ee7f82bf4 | ||
|
|
36cb2e4e5f | ||
|
|
cba2e76568 | ||
|
|
866714a894 | ||
|
|
266c14d6ca | ||
|
|
9a73462ea4 | ||
|
|
f3de4e96a8 | ||
|
|
7f016baa87 | ||
|
|
38f236c725 | ||
|
|
c588c36233 | ||
|
|
cb10eddc26 | ||
|
|
e48cddfbb3 | ||
|
|
1005cbc1e4 | ||
|
|
c48cc08de2 | ||
|
|
12f1bc7c77 | ||
|
|
4ad3f01225 | ||
|
|
8fdffb8da0 | ||
|
|
f30d85310c | ||
|
|
e8bb5d1be5 | ||
|
|
9bd158cc09 | ||
|
|
a3c6a3a34f | ||
|
|
dc60c8d786 | ||
|
|
58c6bc2991 | ||
|
|
5f88b65764 | ||
|
|
1f8eea53a8 | ||
|
|
6f694da912 | ||
|
|
09ec2f39b5 | ||
|
|
383120c534 | ||
|
|
d837e0252f | ||
|
|
b8af93310a | ||
|
|
6de6ab015f | ||
|
|
a01b545441 | ||
|
|
6b03e18975 | ||
|
|
f49d218cfe | ||
|
|
30f0fa95d9 | ||
|
|
ed1ac799c8 | ||
|
|
e66fe1f2e8 | ||
|
|
992ee6dd0b | ||
|
|
262c526c4e | ||
|
|
16ef88754d | ||
|
|
1eaad7d3de | ||
|
|
fd32f0ddf4 | ||
|
|
d3f302d8e2 | ||
|
|
8f44ba1cd6 | ||
|
|
dd6b808acf | ||
|
|
a70287d324 | ||
|
|
fb0f8fc0ae | ||
|
|
096b090caf | ||
|
|
c62b0732d2 | ||
|
|
77832553e5 | ||
|
|
cab2e6ea67 | ||
|
|
7ec8bdf8b1 | ||
|
|
69be54c7b6 | ||
|
|
5550a17391 | ||
|
|
7d1160ddaa | ||
|
|
f03e82a97c | ||
|
|
0909431660 | ||
|
|
3dc33a0a5b | ||
|
|
c90c9938c8 | ||
|
|
9eb59c72c1 | ||
|
|
717d589149 | ||
|
|
65c26357b1 | ||
|
|
2fdbcbdf86 | ||
|
|
c2f0c705e7 | ||
|
|
0e0e53d3b3 | ||
|
|
e1bbe1bf45 | ||
|
|
6f7e7a30e3 | ||
|
|
43f4131d7a | ||
|
|
8a6f48b455 | ||
|
|
a98f75b783 | ||
|
|
05d82fb0d8 | ||
|
|
04bbef0e8b | ||
|
|
a8bd0cb9c2 | ||
|
|
a3f7e72321 | ||
|
|
22e98cf95e | ||
|
|
2c1bbfb902 | ||
|
|
07991dec83 | ||
|
|
8d508712c9 | ||
|
|
dc86d3589c | ||
|
|
3e9ca6c64b | ||
|
|
d0a56a8870 | ||
|
|
af5a845a87 | ||
|
|
3a467b66b6 | ||
|
|
5f89c93274 | ||
|
|
951884b077 | ||
|
|
8b962f23d1 | ||
|
|
5f4a4c6744 | ||
|
|
4084c6186d | ||
|
|
8012bb4216 | ||
|
|
7f1c193a83 | ||
|
|
f572286bf9 | ||
|
|
40833a7524 | ||
|
|
124ff3b034 | ||
|
|
afec2d41b4 | ||
|
|
93f61aa4cc | ||
|
|
aa15a63651 | ||
|
|
3bee38d50f | ||
|
|
cec779e771 | ||
|
|
910462a8e0 | ||
|
|
f2713b663e | ||
|
|
4d6a8224d5 | ||
|
|
98f4dd9857 | ||
|
|
9f9470fc10 | ||
|
|
7d16af8d95 | ||
|
|
436a0784a2 | ||
|
|
71b550c73c | ||
|
|
a228d77f86 | ||
|
|
0970615b1b | ||
|
|
0a2e5afb26 | ||
|
|
209567e7a0 | ||
|
|
d6dfb7f242 | ||
|
|
ecd64f6ed9 | ||
|
|
4dfde7bffc | ||
|
|
2b0d0ddf5d | ||
|
|
7ce9c1944a | ||
|
|
71ff3d7c39 | ||
|
|
95f0094310 | ||
|
|
e7b5e8c8cd | ||
|
|
e7a6e7930f | ||
|
|
4f2a2bfa42 | ||
|
|
7aa766ee65 | ||
|
|
7dcf65a10a | ||
|
|
13dee9db7b | ||
|
|
3d401c11fa | ||
|
|
fd6686d81a | ||
|
|
bcc47d91ca | ||
|
|
11d205f6c4 | ||
|
|
d060b3fa02 | ||
|
|
5bc9fafab8 | ||
|
|
0112da6070 | ||
|
|
1fc4268aea | ||
|
|
1dd1798bfa | ||
|
|
6d6b1773ea | ||
|
|
c4d0237e5c | ||
|
|
aeb15dea30 | ||
|
|
e865a0e2b0 | ||
|
|
345876da33 | ||
|
|
8e1c00f841 | ||
|
|
1c972bc7cb | ||
|
|
eb2fa16fcc | ||
|
|
20cf48b8dd | ||
|
|
65fe0ba7b5 | ||
|
|
2f2aeaeaeb | ||
|
|
3d9e3a17fa | ||
|
|
7e88d6712e | ||
|
|
b1a5b40318 | ||
|
|
ffa1c93f59 | ||
|
|
109d0891e1 | ||
|
|
959285e0c5 | ||
|
|
35423fcf69 | ||
|
|
45c97751fb | ||
|
|
ecc451501c | ||
|
|
a584d04f8a | ||
|
|
0926954cf5 | ||
|
|
71acf87830 | ||
|
|
e93c160a39 | ||
|
|
b48c8db69c | ||
|
|
82c2c5c597 | ||
|
|
d21ebc28af | ||
|
|
80b2b45d60 | ||
|
|
73b3c8fc8c | ||
|
|
961ee321e8 | ||
|
|
8b23ba7d05 | ||
|
|
ff1d0aa027 | ||
|
|
31cdbd68b1 | ||
|
|
a2c42d3cd4 | ||
|
|
06c31f4e91 | ||
|
|
bfcb3562e6 | ||
|
|
d097096ddc | ||
|
|
6d4973e1e0 | ||
|
|
f99f970dc1 | ||
|
|
0157000cab | ||
|
|
9f7683e2a1 | ||
|
|
2636a83d0e | ||
|
|
6dd1af0d1e | ||
|
|
3a8cfbc381 | ||
|
|
e0bdd5d058 | ||
|
|
cccacff564 | ||
|
|
8af50fa97c | ||
|
|
b78df4d48a | ||
|
|
31b5239a2f | ||
|
|
978306565d | ||
|
|
367bfa607c | ||
|
|
641693d61c | ||
|
|
475ab1fb67 | ||
|
|
e5fd36ad78 | ||
|
|
03acab2639 | ||
|
|
a9dc6e07ad | ||
|
|
aa42ae9058 | ||
|
|
5a99940dfa | ||
|
|
3b70968c25 | ||
|
|
3904e4d175 | ||
|
|
d862898fd3 | ||
|
|
b091264c0a | ||
|
|
3c66ee3f57 | ||
|
|
6280c44be1 | ||
|
|
1191eb0e3d | ||
|
|
743d296073 | ||
|
|
d00d6d6dc2 | ||
|
|
e54c81d1d0 | ||
|
|
aedfb82876 | ||
|
|
0cb7eb9b75 | ||
|
|
696711cc17 | ||
|
|
0ff474ff37 | ||
|
|
4637ac732e | ||
|
|
690d3bfafe | ||
|
|
8e42510a71 | ||
|
|
4b525fdda0 | ||
|
|
af3d3c433b | ||
|
|
151b77f9d6 | ||
|
|
7d83056a1b | ||
|
|
7675c3ebf2 | ||
|
|
df6014f1d7 | ||
|
|
93dc2ded6e | ||
|
|
8f6a2353d8 | ||
|
|
2105773874 | ||
|
|
01aa01f310 | ||
|
|
9d2b1820f1 | ||
|
|
16bb541adb | ||
|
|
f95785f22b | ||
|
|
8fad8c4b9b | ||
|
|
1e8f8ee5f1 | ||
|
|
ee976ad704 | ||
|
|
5cbbb48c2e | ||
|
|
ccf091e4a6 | ||
|
|
cc136a58ea | ||
|
|
d88be7cddf | ||
|
|
e107977f75 | ||
|
|
db4247f705 | ||
|
|
6c852fa817 | ||
|
|
f8f9f05ffe | ||
|
|
2f27319baf | ||
|
|
2dd71e64ac | ||
|
|
74b9fa1348 | ||
|
|
a15ff1bade | ||
|
|
4c2e978f1e | ||
|
|
2506bf5b06 | ||
|
|
b9f42814b5 | ||
|
|
b4e595621f | ||
|
|
c987cf1255 | ||
|
|
02581b1603 | ||
|
|
b358f489b9 | ||
|
|
d985da207f | ||
|
|
b26c53368d | ||
|
|
eae6a00651 | ||
|
|
b60a9fce4b | ||
|
|
f79e688e0d | ||
|
|
adbab25bac | ||
|
|
9f1d9d324d | ||
|
|
b7e48058c8 | ||
|
|
84adfa1ba3 | ||
|
|
10d0ce8dde | ||
|
|
10662c4282 | ||
|
|
67df9abdc6 | ||
|
|
a61825c7b8 | ||
|
|
b692985aef | ||
|
|
0686bc8b19 | ||
|
|
0dd9f5397b | ||
|
|
10c2bee9e1 | ||
|
|
7aec8d4e6b | ||
|
|
218110963d | ||
|
|
bc2744da4b | ||
|
|
2e32abc3e2 | ||
|
|
ce4413a0bc | ||
|
|
2a88428f24 | ||
|
|
44d634395b | ||
|
|
d4cc074187 | ||
|
|
d0e8375b53 | ||
|
|
072d1a4b77 | ||
|
|
194ff6ee3d | ||
|
|
730fec1cfd | ||
|
|
f47a5fe52b | ||
|
|
bb3e95c40d | ||
|
|
f8d23b3582 | ||
|
|
17a10f702f | ||
|
|
082e46b48d | ||
|
|
6798f8ea88 | ||
|
|
12764e9db4 | ||
|
|
1016aa045f | ||
|
|
8594292aa4 | ||
|
|
20691894f5 | ||
|
|
f23932bd98 | ||
|
|
a867a4869d | ||
|
|
c0c4791ce7 | ||
|
|
ad038f4046 | ||
|
|
46db698333 | ||
|
|
f79183dac7 | ||
|
|
1ed958fe23 | ||
|
|
6ca078c46e | ||
|
|
a93dc6cdb1 | ||
|
|
7bac5dffcb | ||
|
|
b3fc345aba | ||
|
|
9106187a95 | ||
|
|
9b08399d9e | ||
|
|
153a476957 | ||
|
|
227509547f | ||
|
|
e3f047618b | ||
|
|
91d2e1772d | ||
|
|
3b6849e362 | ||
|
|
0fd73746dd | ||
|
|
17c88a19be | ||
|
|
25f0a3fc8f | ||
|
|
a7a394e7d9 | ||
|
|
07e2487c1d | ||
|
|
1dd9c44d51 | ||
|
|
0a6eb12f05 | ||
|
|
f205efcf18 | ||
|
|
a917718353 | ||
|
|
4099a36468 | ||
|
|
d9d9d525d9 | ||
|
|
9939374c48 | ||
|
|
4055b63b9b | ||
|
|
f0230ce0b5 | ||
|
|
cc370314e7 | ||
|
|
655b4f8fc5 | ||
|
|
004dded0a8 | ||
|
|
0def4f8e38 | ||
|
|
7bc2ddaedc | ||
|
|
949b15d858 | ||
|
|
8a8ecac6a7 | ||
|
|
eead25560f | ||
|
|
1b64961320 | ||
|
|
32308fcf71 | ||
|
|
34de96d06e | ||
|
|
575feb486f | ||
|
|
2ab1d532e8 | ||
|
|
360046e5c3 | ||
|
|
35a8fca379 | ||
|
|
19b0c8a024 | ||
|
|
3088c6105e | ||
|
|
a21bf100f3 | ||
|
|
1bf7ed0348 | ||
|
|
c5623e0471 | ||
|
|
1bf82ddf84 | ||
|
|
6840f471c0 | ||
|
|
90be06bd5b | ||
|
|
cf97cff33b | ||
|
|
855da47777 | ||
|
|
43375c6efb | ||
|
|
ba7f2d129e | ||
|
|
57856fc0d5 | ||
|
|
9904421853 | ||
|
|
5d09649b0b | ||
|
|
d500a92926 | ||
|
|
1f94047475 | ||
|
|
bd54b61746 | ||
|
|
20562a4fb9 | ||
|
|
e7bf6e716b | ||
|
|
32ce18716b | ||
|
|
0f57b9340b | ||
|
|
b2c522ce95 | ||
|
|
54f58d1143 | ||
|
|
485018696a | ||
|
|
1608831c33 | ||
|
|
d3af54444c | ||
|
|
d97cddd876 | ||
|
|
f77821fd63 | ||
|
|
0b32adf9ec | ||
|
|
1ac14d7216 | ||
|
|
4ff276cf52 | ||
|
|
2742153f84 | ||
|
|
646990a7d0 | ||
|
|
8882c6b730 | ||
|
|
35d2efd692 | ||
|
|
fc074a6b9f | ||
|
|
014bf25c0a | ||
|
|
0834712c91 | ||
|
|
fec41e4904 | ||
|
|
fd0acc4faf | ||
|
|
380a3a0834 | ||
|
|
5d61d1c7b0 | ||
|
|
9609b26541 | ||
|
|
7403d8e9a8 | ||
|
|
f0b9d3f477 | ||
|
|
3f3edeec07 | ||
|
|
808b4139ee | ||
|
|
49bf63cdd0 | ||
|
|
d209b032ab | ||
|
|
fc28c8e7f3 | ||
|
|
b7c3cfe049 | ||
|
|
8d7b78f3f7 | ||
|
|
041733d3d1 | ||
|
|
874972b683 | ||
|
|
b546a6e758 | ||
|
|
c6af5bbfe8 | ||
|
|
e92f4c6af8 | ||
|
|
986d60a094 | ||
|
|
6a982faa7d | ||
|
|
c8f258a904 | ||
|
|
726d5d507d | ||
|
|
2238ca8a05 | ||
|
|
8bd442ba8c | ||
|
|
7b1c764088 | ||
|
|
b8af91403d | ||
|
|
e21d8768f9 | ||
|
|
5576972261 | ||
|
|
ba517ab388 | ||
|
|
2b638f550d | ||
|
|
9102a5bb73 | ||
|
|
c8fe9f0064 | ||
|
|
42dac7c5c2 | ||
|
|
d2fef01206 | ||
|
|
9df107f4f0 | ||
|
|
e181f12a7b | ||
|
|
c4b20c5411 | ||
|
|
01a7726cf7 | ||
|
|
309afa53cf | ||
|
|
42f01afe26 | ||
|
|
59936e6d4a | ||
|
|
732af2f6e0 | ||
|
|
458decdeb0 | ||
|
|
4e5ef5b628 | ||
|
|
012933635b | ||
|
|
da32468988 | ||
|
|
ddf94a7b39 | ||
|
|
b56058d7e3 | ||
|
|
d780755340 | ||
|
|
489b990240 | ||
|
|
d15250aae9 | ||
|
|
8965e87fa8 | ||
|
|
114d1caf55 | ||
|
|
b565a9faa7 | ||
|
|
781f79408d | ||
|
|
4651827f20 | ||
|
|
8f7588900a | ||
|
|
0bb82561ba | ||
|
|
2064dc20d4 | ||
|
|
23c5870bd3 | ||
|
|
18939df0a7 | ||
|
|
1d6ab9f9db | ||
|
|
210264f942 | ||
|
|
6b801a8e9e | ||
|
|
b3f91845dc | ||
|
|
46fda6bf4c | ||
|
|
9766f0e110 | ||
|
|
94defc4056 | ||
|
|
b292f7f9ac | ||
|
|
5f177090e3 | ||
|
|
0323dd01b2 | ||
|
|
8487fd2ec2 | ||
|
|
a6b13e6972 | ||
|
|
75254178a0 | ||
|
|
787ead835f | ||
|
|
6e55d8f6a1 | ||
|
|
30f8d8199a | ||
|
|
da078b4c09 | ||
|
|
53a5d00fff | ||
|
|
8161024176 | ||
|
|
a475c435ec | ||
|
|
27033c6277 | ||
|
|
d5e692f7e7 | ||
|
|
94415e8029 | ||
|
|
3485e4bf5a | ||
|
|
7eb8a77ac8 | ||
|
|
24a40f54d9 | ||
|
|
d91e5c25ce | ||
|
|
ded7734c36 | ||
|
|
200d92121f | ||
|
|
7dd76c3411 | ||
|
|
591979b95f | ||
|
|
91786ff958 | ||
|
|
5ffb2668ef | ||
|
|
d7a4f9d31c | ||
|
|
0d6e71df70 | ||
|
|
dcb0f189cc | ||
|
|
5ec01bf3ce | ||
|
|
07063bc5c7 | ||
|
|
fd3efd9bad | ||
|
|
bd50a3457d | ||
|
|
730f0368d0 | ||
|
|
24976b5bfd | ||
|
|
732605f961 | ||
|
|
0004827681 | ||
|
|
1023b2a82c | ||
|
|
d7619d273b | ||
|
|
25eeafde23 | ||
|
|
4b39b6f7ce | ||
|
|
21460a5b14 | ||
|
|
162d593514 | ||
|
|
9e0a5cc551 | ||
|
|
d6a8fb20e7 | ||
|
|
8eb15d3d2d | ||
|
|
a93173b56a | ||
|
|
d55b105dae | ||
|
|
bd93c3067e | ||
|
|
bfb775ce62 | ||
|
|
3099323976 | ||
|
|
45d2f4301f | ||
|
|
2cb408f9b1 | ||
|
|
87c5ad4c2c | ||
|
|
2db2d04a37 | ||
|
|
315f3d5df1 | ||
|
|
8cc2738609 | ||
|
|
674c998e93 | ||
|
|
be54dde0eb | ||
|
|
a1ab7f7c94 | ||
|
|
1f6645b19f | ||
|
|
20a5f939ba | ||
|
|
bf2d13cfa0 | ||
|
|
86e0f9b912 | ||
|
|
36b1b4af2f | ||
|
|
d4220a76da | ||
|
|
10e8a2a05c | ||
|
|
64ac64fb66 | ||
|
|
491483d599 | ||
|
|
7574f586aa | ||
|
|
21ed31e33a | ||
|
|
e2c0d69c9c | ||
|
|
7bc9d453c2 | ||
|
|
c32efd9118 | ||
|
|
7354547bd8 | ||
|
|
a8ee83e2c5 | ||
|
|
e8ca30a5c7 | ||
|
|
bd2a6d5386 | ||
|
|
9189fe007b | ||
|
|
85ad0c276c | ||
|
|
65888d95c9 | ||
|
|
6908fb0de3 | ||
|
|
72c8f7700b | ||
|
|
88f2d234a4 | ||
|
|
ccdd2e6650 | ||
|
|
d7fdc01f7f | ||
|
|
02e3c046aa | ||
|
|
d0f1a838a6 | ||
|
|
5f121396e9 | ||
|
|
4c01ce9f43 | ||
|
|
f5936d132a | ||
|
|
a95ea31a4e | ||
|
|
3511d1f8a2 | ||
|
|
4cdc4ed7db | ||
|
|
4b6a0c42c8 | ||
|
|
3672f66c74 | ||
|
|
93cd2ab224 | ||
|
|
bc53ebd4a0 | ||
|
|
6f2bae019f | ||
|
|
df86576989 | ||
|
|
c3e2b7347b | ||
|
|
ba46495e11 | ||
|
|
807934f00c | ||
|
|
53d9cac196 | ||
|
|
23e26e589f | ||
|
|
3a6d3f1a5b | ||
|
|
916c4db75b |
12
.github/workflows/checklocks.yml
vendored
12
.github/workflows/checklocks.yml
vendored
@@ -18,11 +18,17 @@ jobs:
|
||||
runs-on: [ ubuntu-latest ]
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
|
||||
|
||||
- name: Build checklocks
|
||||
run: ./tool/go build -o /tmp/checklocks gvisor.dev/gvisor/tools/checklocks/cmd/checklocks
|
||||
|
||||
- name: Run checklocks vet
|
||||
# TODO: remove || true once we have applied checklocks annotations everywhere.
|
||||
run: ./tool/go vet -vettool=/tmp/checklocks ./... || true
|
||||
# TODO(#12625): add more packages as we add annotations
|
||||
run: |-
|
||||
./tool/go vet -vettool=/tmp/checklocks \
|
||||
./envknob \
|
||||
./ipn/store/mem \
|
||||
./net/stun/stuntest \
|
||||
./net/wsconn \
|
||||
./proxymap
|
||||
|
||||
10
.github/workflows/codeql-analysis.yml
vendored
10
.github/workflows/codeql-analysis.yml
vendored
@@ -45,17 +45,17 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
|
||||
|
||||
# Install a more recent Go that understands modern go.mod content.
|
||||
- name: Install Go
|
||||
uses: actions/setup-go@v4
|
||||
uses: actions/setup-go@41dfa10bad2bb2ae585af6ee5bb4d7d973ad74ed # v5.1.0
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v2
|
||||
uses: github/codeql-action/init@4f3212b61783c3c68e8309a0f18a699764811cda # v3.27.1
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
# If you wish to specify custom queries, you can do so here or in a config file.
|
||||
@@ -66,7 +66,7 @@ jobs:
|
||||
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
|
||||
# If this step fails, then you should remove it and run the build manually (see below)
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@v2
|
||||
uses: github/codeql-action/autobuild@4f3212b61783c3c68e8309a0f18a699764811cda # v3.27.1
|
||||
|
||||
# ℹ️ Command-line programs to run using the OS shell.
|
||||
# 📚 https://git.io/JvXDl
|
||||
@@ -80,4 +80,4 @@ jobs:
|
||||
# make release
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v2
|
||||
uses: github/codeql-action/analyze@4f3212b61783c3c68e8309a0f18a699764811cda # v3.27.1
|
||||
|
||||
2
.github/workflows/docker-file-build.yml
vendored
2
.github/workflows/docker-file-build.yml
vendored
@@ -10,6 +10,6 @@ jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
|
||||
- name: "Build Docker image"
|
||||
run: docker build .
|
||||
|
||||
@@ -17,7 +17,7 @@ jobs:
|
||||
id-token: "write"
|
||||
contents: "read"
|
||||
steps:
|
||||
- uses: "actions/checkout@v4"
|
||||
- uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
|
||||
with:
|
||||
ref: "${{ (inputs.tag != null) && format('refs/tags/{0}', inputs.tag) || '' }}"
|
||||
- uses: "DeterminateSystems/nix-installer-action@main"
|
||||
|
||||
10
.github/workflows/golangci-lint.yml
vendored
10
.github/workflows/golangci-lint.yml
vendored
@@ -23,18 +23,18 @@ jobs:
|
||||
name: lint
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
|
||||
|
||||
- uses: actions/setup-go@v4
|
||||
- uses: actions/setup-go@41dfa10bad2bb2ae585af6ee5bb4d7d973ad74ed # v5.1.0
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: false
|
||||
|
||||
- name: golangci-lint
|
||||
# Note: this is the 'v3' tag as of 2023-08-14
|
||||
uses: golangci/golangci-lint-action@639cd343e1d3b897ff35927a75193d57cfcba299
|
||||
# Note: this is the 'v6.1.0' tag as of 2024-08-21
|
||||
uses: golangci/golangci-lint-action@aaa42aa0628b4ae2578232a66b541047968fac86
|
||||
with:
|
||||
version: v1.56
|
||||
version: v1.60
|
||||
|
||||
# Show only new issues if it's a pull request.
|
||||
only-new-issues: true
|
||||
|
||||
4
.github/workflows/govulncheck.yml
vendored
4
.github/workflows/govulncheck.yml
vendored
@@ -14,7 +14,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Check out code into the Go module directory
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
|
||||
|
||||
- name: Install govulncheck
|
||||
run: ./tool/go install golang.org/x/vuln/cmd/govulncheck@latest
|
||||
@@ -24,7 +24,7 @@ jobs:
|
||||
|
||||
- name: Post to slack
|
||||
if: failure() && github.event_name == 'schedule'
|
||||
uses: slackapi/slack-github-action@v1.24.0
|
||||
uses: slackapi/slack-github-action@37ebaef184d7626c5f204ab8d3baff4262dd30f0 # v1.27.0
|
||||
env:
|
||||
SLACK_BOT_TOKEN: ${{ secrets.GOVULNCHECK_BOT_TOKEN }}
|
||||
with:
|
||||
|
||||
7
.github/workflows/installer.yml
vendored
7
.github/workflows/installer.yml
vendored
@@ -67,6 +67,11 @@ jobs:
|
||||
image: ${{ matrix.image }}
|
||||
options: --user root
|
||||
steps:
|
||||
- name: install dependencies (pacman)
|
||||
# Refresh the package databases to ensure that the tailscale package is
|
||||
# defined.
|
||||
run: pacman -Sy
|
||||
if: contains(matrix.image, 'archlinux')
|
||||
- name: install dependencies (yum)
|
||||
# tar and gzip are needed by the actions/checkout below.
|
||||
run: yum install -y --allowerasing tar gzip ${{ matrix.deps }}
|
||||
@@ -93,7 +98,7 @@ jobs:
|
||||
# We cannot use v4, as it requires a newer glibc version than some of the
|
||||
# tested images provide. See
|
||||
# https://github.com/actions/checkout/issues/1487
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
|
||||
- name: run installer
|
||||
run: scripts/installer.sh
|
||||
# Package installation can fail in docker because systemd is not running
|
||||
|
||||
2
.github/workflows/kubemanifests.yaml
vendored
2
.github/workflows/kubemanifests.yaml
vendored
@@ -17,7 +17,7 @@ jobs:
|
||||
runs-on: [ ubuntu-latest ]
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
|
||||
- name: Build and lint Helm chart
|
||||
run: |
|
||||
eval `./tool/go run ./cmd/mkversion`
|
||||
|
||||
23
.github/workflows/ssh-integrationtest.yml
vendored
Normal file
23
.github/workflows/ssh-integrationtest.yml
vendored
Normal file
@@ -0,0 +1,23 @@
|
||||
# Run the ssh integration tests with `make sshintegrationtest`.
|
||||
# These tests can also be running locally.
|
||||
name: "ssh-integrationtest"
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-$${{ github.head_ref || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- "ssh/**"
|
||||
- "tempfork/gliderlabs/ssh/**"
|
||||
- ".github/workflows/ssh-integrationtest"
|
||||
jobs:
|
||||
ssh-integrationtest:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
|
||||
- name: Run SSH integration tests
|
||||
run: |
|
||||
make sshintegrationtest
|
||||
63
.github/workflows/test.yml
vendored
63
.github/workflows/test.yml
vendored
@@ -50,7 +50,7 @@ jobs:
|
||||
- shard: '4/4'
|
||||
steps:
|
||||
- name: checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
|
||||
- name: build test wrapper
|
||||
run: ./tool/go build -o /tmp/testwrapper ./cmd/testwrapper
|
||||
- name: integration tests as root
|
||||
@@ -78,9 +78,9 @@ jobs:
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
|
||||
- name: Restore Cache
|
||||
uses: actions/cache@v3
|
||||
uses: actions/cache@6849a6489940f00c2f30c0fb92c6274307ccb58a # v4.1.2
|
||||
with:
|
||||
# Note: unlike the other setups, this is only grabbing the mod download
|
||||
# cache, rather than the whole mod directory, as the download cache
|
||||
@@ -150,16 +150,16 @@ jobs:
|
||||
runs-on: windows-2022
|
||||
steps:
|
||||
- name: checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
|
||||
|
||||
- name: Install Go
|
||||
uses: actions/setup-go@v4
|
||||
uses: actions/setup-go@41dfa10bad2bb2ae585af6ee5bb4d7d973ad74ed # v5.1.0
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: false
|
||||
|
||||
- name: Restore Cache
|
||||
uses: actions/cache@v3
|
||||
uses: actions/cache@6849a6489940f00c2f30c0fb92c6274307ccb58a # v4.1.2
|
||||
with:
|
||||
# Note: unlike the other setups, this is only grabbing the mod download
|
||||
# cache, rather than the whole mod directory, as the download cache
|
||||
@@ -190,11 +190,11 @@ jobs:
|
||||
options: --privileged
|
||||
steps:
|
||||
- name: checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
|
||||
- name: chown
|
||||
run: chown -R $(id -u):$(id -g) $PWD
|
||||
- name: privileged tests
|
||||
run: ./tool/go test ./util/linuxfw
|
||||
run: ./tool/go test ./util/linuxfw ./derp/xdp
|
||||
|
||||
vm:
|
||||
runs-on: ["self-hosted", "linux", "vm"]
|
||||
@@ -202,7 +202,7 @@ jobs:
|
||||
if: github.repository == 'tailscale/tailscale'
|
||||
steps:
|
||||
- name: checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
|
||||
- name: Run VM tests
|
||||
run: ./tool/go test ./tstest/integration/vms -v -no-s3 -run-vm-tests -run=TestRunUbuntu2004
|
||||
env:
|
||||
@@ -214,7 +214,7 @@ jobs:
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
|
||||
- name: build all
|
||||
run: ./tool/go install -race ./cmd/...
|
||||
- name: build tests
|
||||
@@ -258,9 +258,9 @@ jobs:
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
|
||||
- name: Restore Cache
|
||||
uses: actions/cache@v3
|
||||
uses: actions/cache@6849a6489940f00c2f30c0fb92c6274307ccb58a # v4.1.2
|
||||
with:
|
||||
# Note: unlike the other setups, this is only grabbing the mod download
|
||||
# cache, rather than the whole mod directory, as the download cache
|
||||
@@ -295,7 +295,7 @@ jobs:
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
|
||||
- name: build some
|
||||
run: ./tool/go build ./ipn/... ./wgengine/ ./types/... ./control/controlclient
|
||||
env:
|
||||
@@ -317,9 +317,9 @@ jobs:
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
|
||||
- name: Restore Cache
|
||||
uses: actions/cache@v3
|
||||
uses: actions/cache@6849a6489940f00c2f30c0fb92c6274307ccb58a # v4.1.2
|
||||
with:
|
||||
# Note: unlike the other setups, this is only grabbing the mod download
|
||||
# cache, rather than the whole mod directory, as the download cache
|
||||
@@ -350,7 +350,7 @@ jobs:
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
|
||||
# Super minimal Android build that doesn't even use CGO and doesn't build everything that's needed
|
||||
# and is only arm64. But it's a smoke build: it's not meant to catch everything. But it'll catch
|
||||
# some Android breakages early.
|
||||
@@ -365,9 +365,9 @@ jobs:
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
|
||||
- name: Restore Cache
|
||||
uses: actions/cache@v3
|
||||
uses: actions/cache@6849a6489940f00c2f30c0fb92c6274307ccb58a # v4.1.2
|
||||
with:
|
||||
# Note: unlike the other setups, this is only grabbing the mod download
|
||||
# cache, rather than the whole mod directory, as the download cache
|
||||
@@ -399,7 +399,7 @@ jobs:
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
|
||||
- name: test tailscale_go
|
||||
run: ./tool/go test -tags=tailscale_go,ts_enable_sockstats ./net/sockstats/...
|
||||
|
||||
@@ -456,18 +456,22 @@ jobs:
|
||||
fuzz-seconds: 300
|
||||
dry-run: false
|
||||
language: go
|
||||
- name: Set artifacts_path in env (workaround for actions/upload-artifact#176)
|
||||
if: steps.run.outcome != 'success' && steps.build.outcome == 'success'
|
||||
run: |
|
||||
echo "artifacts_path=$(realpath .)" >> $GITHUB_ENV
|
||||
- name: upload crash
|
||||
uses: actions/upload-artifact@v3
|
||||
uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # v4.4.3
|
||||
if: steps.run.outcome != 'success' && steps.build.outcome == 'success'
|
||||
with:
|
||||
name: artifacts
|
||||
path: ./out/artifacts
|
||||
path: ${{ env.artifacts_path }}/out/artifacts
|
||||
|
||||
depaware:
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
|
||||
- name: check depaware
|
||||
run: |
|
||||
export PATH=$(./tool/go env GOROOT)/bin:$PATH
|
||||
@@ -477,10 +481,10 @@ jobs:
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
|
||||
- name: check that 'go generate' is clean
|
||||
run: |
|
||||
pkgs=$(./tool/go list ./... | grep -Ev 'dnsfallback|k8s-operator')
|
||||
pkgs=$(./tool/go list ./... | grep -Ev 'dnsfallback|k8s-operator|xdp')
|
||||
./tool/go generate $pkgs
|
||||
echo
|
||||
echo
|
||||
@@ -490,7 +494,7 @@ jobs:
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
|
||||
- name: check that 'go mod tidy' is clean
|
||||
run: |
|
||||
./tool/go mod tidy
|
||||
@@ -502,7 +506,7 @@ jobs:
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
|
||||
- name: check licenses
|
||||
run: ./scripts/check_license_headers.sh .
|
||||
|
||||
@@ -518,7 +522,7 @@ jobs:
|
||||
goarch: "386"
|
||||
steps:
|
||||
- name: checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
|
||||
- name: install staticcheck
|
||||
run: GOBIN=~/.local/bin ./tool/go install honnef.co/go/tools/cmd/staticcheck
|
||||
- name: run staticcheck
|
||||
@@ -559,7 +563,7 @@ jobs:
|
||||
# By having the job always run, but skipping its only step as needed, we
|
||||
# let the CI output collapse nicely in PRs.
|
||||
if: failure() && github.event_name == 'push'
|
||||
uses: ruby/action-slack@v3.2.1
|
||||
uses: slackapi/slack-github-action@37ebaef184d7626c5f204ab8d3baff4262dd30f0 # v1.27.0
|
||||
with:
|
||||
payload: |
|
||||
{
|
||||
@@ -574,6 +578,7 @@ jobs:
|
||||
}
|
||||
env:
|
||||
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
|
||||
SLACK_WEBHOOK_TYPE: INCOMING_WEBHOOK
|
||||
|
||||
check_mergeability:
|
||||
if: always()
|
||||
@@ -596,6 +601,6 @@ jobs:
|
||||
steps:
|
||||
- name: Decide if change is okay to merge
|
||||
if: github.event_name != 'push'
|
||||
uses: re-actors/alls-green@release/v1
|
||||
uses: re-actors/alls-green@05ac9388f0aebcb5727afa17fcccfecd6f8ec5fe # v1.2.2
|
||||
with:
|
||||
jobs: ${{ toJSON(needs) }}
|
||||
|
||||
9
.github/workflows/update-flake.yml
vendored
9
.github/workflows/update-flake.yml
vendored
@@ -21,21 +21,22 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
|
||||
|
||||
- name: Run update-flakes
|
||||
run: ./update-flake.sh
|
||||
|
||||
- name: Get access token
|
||||
uses: tibdex/github-app-token@b62528385c34dbc9f38e5f4225ac829252d1ea92 # v1.8.0
|
||||
uses: tibdex/github-app-token@3beb63f4bd073e61482598c45c71c1019b59b73a # v2.1.0
|
||||
id: generate-token
|
||||
with:
|
||||
app_id: ${{ secrets.LICENSING_APP_ID }}
|
||||
installation_id: ${{ secrets.LICENSING_APP_INSTALLATION_ID }}
|
||||
installation_retrieval_mode: "id"
|
||||
installation_retrieval_payload: ${{ secrets.LICENSING_APP_INSTALLATION_ID }}
|
||||
private_key: ${{ secrets.LICENSING_APP_PRIVATE_KEY }}
|
||||
|
||||
- name: Send pull request
|
||||
uses: peter-evans/create-pull-request@284f54f989303d2699d373481a0cfa13ad5a6666 #v5.0.1
|
||||
uses: peter-evans/create-pull-request@5e914681df9dc83aa4e4905692ca88beb2f9e91f #v7.0.5
|
||||
with:
|
||||
token: ${{ steps.generate-token.outputs.token }}
|
||||
author: Flakes Updater <noreply+flakes-updater@tailscale.com>
|
||||
|
||||
@@ -14,7 +14,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
|
||||
|
||||
- name: Run go get
|
||||
run: |
|
||||
@@ -23,18 +23,19 @@ jobs:
|
||||
./tool/go mod tidy
|
||||
|
||||
- name: Get access token
|
||||
uses: tibdex/github-app-token@b62528385c34dbc9f38e5f4225ac829252d1ea92 # v1.8.0
|
||||
uses: tibdex/github-app-token@3beb63f4bd073e61482598c45c71c1019b59b73a # v2.1.0
|
||||
id: generate-token
|
||||
with:
|
||||
# TODO(will): this should use the code updater app rather than licensing.
|
||||
# It has the same permissions, so not a big deal, but still.
|
||||
app_id: ${{ secrets.LICENSING_APP_ID }}
|
||||
installation_id: ${{ secrets.LICENSING_APP_INSTALLATION_ID }}
|
||||
installation_retrieval_mode: "id"
|
||||
installation_retrieval_payload: ${{ secrets.LICENSING_APP_INSTALLATION_ID }}
|
||||
private_key: ${{ secrets.LICENSING_APP_PRIVATE_KEY }}
|
||||
|
||||
- name: Send pull request
|
||||
id: pull-request
|
||||
uses: peter-evans/create-pull-request@284f54f989303d2699d373481a0cfa13ad5a6666 #v5.0.1
|
||||
uses: peter-evans/create-pull-request@5e914681df9dc83aa4e4905692ca88beb2f9e91f #v7.0.5
|
||||
with:
|
||||
token: ${{ steps.generate-token.outputs.token }}
|
||||
author: OSS Updater <noreply+oss-updater@tailscale.com>
|
||||
|
||||
2
.github/workflows/webclient.yml
vendored
2
.github/workflows/webclient.yml
vendored
@@ -24,7 +24,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
|
||||
- name: Install deps
|
||||
run: ./tool/yarn --cwd client/web
|
||||
- name: Run lint
|
||||
|
||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -43,3 +43,9 @@ client/web/build/assets
|
||||
|
||||
/gocross
|
||||
/dist
|
||||
|
||||
# Ignore xcode userstate and workspace data
|
||||
*.xcuserstate
|
||||
*.xcworkspacedata
|
||||
/tstest/tailmac/bin
|
||||
/tstest/tailmac/build
|
||||
|
||||
18
Dockerfile
18
Dockerfile
@@ -1,17 +1,13 @@
|
||||
# Copyright (c) Tailscale Inc & AUTHORS
|
||||
# SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
############################################################################
|
||||
# Note that this Dockerfile is currently NOT used to build any of the published
|
||||
# Tailscale container images and may have drifted from the image build mechanism
|
||||
# we use.
|
||||
# Tailscale images are currently built using https://github.com/tailscale/mkctr,
|
||||
# and the build script can be found in ./build_docker.sh.
|
||||
#
|
||||
# WARNING: Tailscale is not yet officially supported in container
|
||||
# environments, such as Docker and Kubernetes. Though it should work, we
|
||||
# don't regularly test it, and we know there are some feature limitations.
|
||||
#
|
||||
# See current bugs tagged "containers":
|
||||
# https://github.com/tailscale/tailscale/labels/containers
|
||||
#
|
||||
############################################################################
|
||||
|
||||
# This Dockerfile includes all the tailscale binaries.
|
||||
#
|
||||
# To build the Dockerfile:
|
||||
@@ -31,7 +27,7 @@
|
||||
# $ docker exec tailscaled tailscale status
|
||||
|
||||
|
||||
FROM golang:1.22-alpine AS build-env
|
||||
FROM golang:1.23-alpine AS build-env
|
||||
|
||||
WORKDIR /go/src/tailscale
|
||||
|
||||
@@ -46,7 +42,7 @@ RUN go install \
|
||||
gvisor.dev/gvisor/pkg/tcpip/stack \
|
||||
golang.org/x/crypto/ssh \
|
||||
golang.org/x/crypto/acme \
|
||||
nhooyr.io/websocket \
|
||||
github.com/coder/websocket \
|
||||
github.com/mdlayher/netlink
|
||||
|
||||
COPY . .
|
||||
|
||||
9
Makefile
9
Makefile
@@ -21,6 +21,7 @@ updatedeps: ## Update depaware deps
|
||||
tailscale.com/cmd/tailscaled \
|
||||
tailscale.com/cmd/tailscale \
|
||||
tailscale.com/cmd/derper \
|
||||
tailscale.com/cmd/k8s-operator \
|
||||
tailscale.com/cmd/stund
|
||||
|
||||
depaware: ## Run depaware checks
|
||||
@@ -30,6 +31,7 @@ depaware: ## Run depaware checks
|
||||
tailscale.com/cmd/tailscaled \
|
||||
tailscale.com/cmd/tailscale \
|
||||
tailscale.com/cmd/derper \
|
||||
tailscale.com/cmd/k8s-operator \
|
||||
tailscale.com/cmd/stund
|
||||
|
||||
buildwindows: ## Build tailscale CLI for windows/amd64
|
||||
@@ -110,12 +112,13 @@ publishdevnameserver: ## Build and publish k8s-nameserver image to location spec
|
||||
|
||||
.PHONY: sshintegrationtest
|
||||
sshintegrationtest: ## Run the SSH integration tests in various Docker containers
|
||||
@GOOS=linux GOARCH=amd64 go test -tags integrationtest -c ./ssh/tailssh -o ssh/tailssh/testcontainers/tailssh.test && \
|
||||
GOOS=linux GOARCH=amd64 go build -o ssh/tailssh/testcontainers/tailscaled ./cmd/tailscaled && \
|
||||
@GOOS=linux GOARCH=amd64 ./tool/go test -tags integrationtest -c ./ssh/tailssh -o ssh/tailssh/testcontainers/tailssh.test && \
|
||||
GOOS=linux GOARCH=amd64 ./tool/go build -o ssh/tailssh/testcontainers/tailscaled ./cmd/tailscaled && \
|
||||
echo "Testing on ubuntu:focal" && docker build --build-arg="BASE=ubuntu:focal" -t ssh-ubuntu-focal ssh/tailssh/testcontainers && \
|
||||
echo "Testing on ubuntu:jammy" && docker build --build-arg="BASE=ubuntu:jammy" -t ssh-ubuntu-jammy ssh/tailssh/testcontainers && \
|
||||
echo "Testing on ubuntu:mantic" && docker build --build-arg="BASE=ubuntu:mantic" -t ssh-ubuntu-mantic ssh/tailssh/testcontainers && \
|
||||
echo "Testing on ubuntu:noble" && docker build --build-arg="BASE=ubuntu:noble" -t ssh-ubuntu-noble ssh/tailssh/testcontainers
|
||||
echo "Testing on ubuntu:noble" && docker build --build-arg="BASE=ubuntu:noble" -t ssh-ubuntu-noble ssh/tailssh/testcontainers && \
|
||||
echo "Testing on alpine:latest" && docker build --build-arg="BASE=alpine:latest" -t ssh-alpine-latest ssh/tailssh/testcontainers
|
||||
|
||||
help: ## Show this help
|
||||
@echo "\nSpecify a command. The choices are:\n"
|
||||
|
||||
@@ -37,7 +37,7 @@ not open source.
|
||||
|
||||
## Building
|
||||
|
||||
We always require the latest Go release, currently Go 1.22. (While we build
|
||||
We always require the latest Go release, currently Go 1.23. (While we build
|
||||
releases with our [Go fork](https://github.com/tailscale/go/), its use is not
|
||||
required.)
|
||||
|
||||
|
||||
@@ -1 +1 @@
|
||||
1.67.0
|
||||
1.77.0
|
||||
|
||||
103
api.md
103
api.md
@@ -1,101 +1,2 @@
|
||||
# Tailscale API
|
||||
|
||||
The Tailscale API documentation is located in **[tailscale/publicapi](./publicapi/readme.md#tailscale-api)**.
|
||||
|
||||
# APIs
|
||||
|
||||
**[Overview](./publicapi/readme.md)**
|
||||
|
||||
**[Device](./publicapi/device.md#device)**
|
||||
|
||||
<a href="device-delete"></a>
|
||||
<a href="expire-device-key"></a>
|
||||
<a href="device-routes-get">
|
||||
<a href="device-routes-post"></a>
|
||||
<a href="#device-authorized-post"></a>
|
||||
<a href="device-tags-post"></a>
|
||||
<a href="device-key-post"></a>
|
||||
<a href="tailnet-acl-get"></a>
|
||||
|
||||
- Get a device: [`GET /api/v2/device/{deviceid}`](./publicapi/device.md#get-device)
|
||||
- Delete a device: [`DELETE /api/v2/device/{deviceID}`](./publicapi/device.md#delete-device)
|
||||
- Expire device key: [`POST /api/v2/device/{deviceID}/expire`](./publicapi/device.md#expire-device-key)
|
||||
- [**Routes**](./publicapi/device.md#routes)
|
||||
- Get device routes: [`GET /api/v2/device/{deviceID}/routes`](./publicapi/device.md#get-device-routes)
|
||||
- Set device routes: [`POST /api/v2/device/{deviceID}/routes`](./publicapi/device.md#set-device-routes)
|
||||
- [**Authorize**](./publicapi/device.md#authorize)
|
||||
- Authorize a device: [`POST /api/v2/device/{deviceID}/authorized`](./publicapi/device.md#authorize-device)
|
||||
- [**Tags**](./publicapi/device.md#tags)
|
||||
- Update tags: [`POST /api/v2/device/{deviceID}/tags`](./publicapi/device.md#update-device-tags)
|
||||
- [**Keys**](./publicapi/device.md#keys)
|
||||
- Update device key: [`POST /api/v2/device/{deviceID}/key`](./publicapi/device.md#update-device-key)
|
||||
- [**IP Addresses**](./publicapi/device.md#ip-addresses)
|
||||
- Set device IPv4 address: [`POST /api/v2/device/{deviceID}/ip`](./publicapi/device.md#set-device-ipv4-address)
|
||||
- [**Device posture attributes**](./publicapi/device.md#device-posture-attributes)
|
||||
- Get device posture attributes: [`GET /api/v2/device/{deviceID}/attributes`](./publicapi/device.md#get-device-posture-attributes)
|
||||
- Set custom device posture attributes: [`POST /api/v2/device/{deviceID}/attributes/{attributeKey}`](./publicapi/device.md#set-device-posture-attributes)
|
||||
- Delete custom device posture attributes: [`DELETE /api/v2/device/{deviceID}/attributes/{attributeKey}`](./publicapi/device.md#delete-custom-device-posture-attributes)
|
||||
- [**Device invites**](./publicapi/device.md#invites-to-a-device)
|
||||
- List device invites: [`GET /api/v2/device/{deviceID}/device-invites`](./publicapi/device.md#list-device-invites)
|
||||
- Create device invites: [`POST /api/v2/device/{deviceID}/device-invites`](./publicapi/device.md#create-device-invites)
|
||||
|
||||
**[Tailnet](./publicapi/tailnet.md#tailnet)**
|
||||
|
||||
<a href="tailnet-acl-post"></a>
|
||||
<a href="tailnet-acl-preview-post"></a>
|
||||
<a href="tailnet-acl-validate-post"></a>
|
||||
<a href="tailnet-devices"></a>
|
||||
<a href="tailnet-keys-get"></a>
|
||||
<a href="tailnet-keys-post"></a>
|
||||
<a href="tailnet-keys-key-get"></a>
|
||||
<a href="tailnet-keys-key-delete"></a>
|
||||
<a href="tailnet-dns"></a>
|
||||
<a href="tailnet-dns-nameservers-get"></a>
|
||||
<a href="tailnet-dns-nameservers-post"></a>
|
||||
<a href="tailnet-dns-preferences-get"></a>
|
||||
<a href="tailnet-dns-preferences-post"></a>
|
||||
<a href="tailnet-dns-searchpaths-get"></a>
|
||||
<a href="tailnet-dns-searchpaths-post"></a>
|
||||
|
||||
- [**Policy File**](./publicapi/tailnet.md#policy-file)
|
||||
- Get policy file: [`GET /api/v2/tailnet/{tailnet}/acl`](./publicapi/tailnet.md#get-policy-file)
|
||||
- Update policy file: [`POST /api/v2/tailnet/{tailnet}/acl`](./publicapi/tailnet.md#update-policy-file)
|
||||
- Preview rule matches: [`POST /api/v2/tailnet/{tailnet}/acl/preview`](./publicapi/tailnet.md#preview-policy-file-rule-matches)
|
||||
- Validate and test policy file: [`POST /api/v2/tailnet/{tailnet}/acl/validate`](./publicapi/tailnet.md#validate-and-test-policy-file)
|
||||
- [**Devices**](./publicapi/tailnet.md#devices)
|
||||
- List tailnet devices: [`GET /api/v2/tailnet/{tailnet}/devices`](./publicapi/tailnet.md#list-tailnet-devices)
|
||||
- [**Keys**](./publicapi/tailnet.md#tailnet-keys)
|
||||
- List tailnet keys: [`GET /api/v2/tailnet/{tailnet}/keys`](./publicapi/tailnet.md#list-tailnet-keys)
|
||||
- Create an auth key: [`POST /api/v2/tailnet/{tailnet}/keys`](./publicapi/tailnet.md#create-auth-key)
|
||||
- Get a key: [`GET /api/v2/tailnet/{tailnet}/keys/{keyid}`](./publicapi/tailnet.md#get-key)
|
||||
- Delete a key: [`DELETE /api/v2/tailnet/{tailnet}/keys/{keyid}`](./publicapi/tailnet.md#delete-key)
|
||||
- [**DNS**](./publicapi/tailnet.md#dns)
|
||||
- [**Nameservers**](./publicapi/tailnet.md#nameservers)
|
||||
- Get nameservers: [`GET /api/v2/tailnet/{tailnet}/dns/nameservers`](./publicapi/tailnet.md#get-nameservers)
|
||||
- Set nameservers: [`POST /api/v2/tailnet/{tailnet}/dns/nameservers`](./publicapi/tailnet.md#set-nameservers)
|
||||
- [**Preferences**](./publicapi/tailnet.md#preferences)
|
||||
- Get DNS preferences: [`GET /api/v2/tailnet/{tailnet}/dns/preferences`](./publicapi/tailnet.md#get-dns-preferences)
|
||||
- Set DNS preferences: [`POST /api/v2/tailnet/{tailnet}/dns/preferences`](./publicapi/tailnet.md#set-dns-preferences)
|
||||
- [**Search Paths**](./publicapi/tailnet.md#search-paths)
|
||||
- Get search paths: [`GET /api/v2/tailnet/{tailnet}/dns/searchpaths`](./publicapi/tailnet.md#get-search-paths)
|
||||
- Set search paths: [`POST /api/v2/tailnet/{tailnet}/dns/searchpaths`](./publicapi/tailnet.md#set-search-paths)
|
||||
- [**Split DNS**](./publicapi/tailnet.md#split-dns)
|
||||
- Get split DNS: [`GET /api/v2/tailnet/{tailnet}/dns/split-dns`](./publicapi/tailnet.md#get-split-dns)
|
||||
- Update split DNS: [`PATCH /api/v2/tailnet/{tailnet}/dns/split-dns`](./publicapi/tailnet.md#update-split-dns)
|
||||
- Set split DNS: [`PUT /api/v2/tailnet/{tailnet}/dns/split-dns`](./publicapi/tailnet.md#set-split-dns)
|
||||
- [**User invites**](./publicapi/tailnet.md#tailnet-user-invites)
|
||||
- List user invites: [`GET /api/v2/tailnet/{tailnet}/user-invites`](./publicapi/tailnet.md#list-user-invites)
|
||||
- Create user invites: [`POST /api/v2/tailnet/{tailnet}/user-invites`](./publicapi/tailnet.md#create-user-invites)
|
||||
|
||||
**[User invites](./publicapi/userinvites.md#user-invites)**
|
||||
|
||||
- Get user invite: [`GET /api/v2/user-invites/{userInviteId}`](./publicapi/userinvites.md#get-user-invite)
|
||||
- Delete user invite: [`DELETE /api/v2/user-invites/{userInviteId}`](./publicapi/userinvites.md#delete-user-invite)
|
||||
- Resend user invite (by email): [`POST /api/v2/user-invites/{userInviteId}/resend`](#resend-user-invite)
|
||||
|
||||
**[Device invites](./publicapi/deviceinvites.md#device-invites)**
|
||||
|
||||
- Get device invite: [`GET /api/v2/device-invites/{deviceInviteId}`](./publicapi/deviceinvites.md#get-device-invite)
|
||||
- Delete device invite: [`DELETE /api/v2/device-invites/{deviceInviteId}`](./publicapi/deviceinvites.md#delete-device-invite)
|
||||
- Resend device invite (by email): [`POST /api/v2/device-invites/{deviceInviteId}/resend`](./publicapi/deviceinvites.md#resend-device-invite)
|
||||
- Accept device invite [`POST /api/v2/device-invites/-/accept`](#accept-device-invite)
|
||||
> [!IMPORTANT]
|
||||
> The Tailscale API documentation has moved to https://tailscale.com/api
|
||||
|
||||
@@ -11,6 +11,7 @@ package appc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"slices"
|
||||
"strings"
|
||||
@@ -21,6 +22,7 @@ import (
|
||||
"golang.org/x/net/dns/dnsmessage"
|
||||
"tailscale.com/types/logger"
|
||||
"tailscale.com/types/views"
|
||||
"tailscale.com/util/clientmetric"
|
||||
"tailscale.com/util/dnsname"
|
||||
"tailscale.com/util/execqueue"
|
||||
"tailscale.com/util/mak"
|
||||
@@ -35,7 +37,7 @@ type rateLogger struct {
|
||||
periodStart time.Time
|
||||
periodCount int64
|
||||
now func() time.Time
|
||||
callback func(int64, time.Time)
|
||||
callback func(int64, time.Time, int64)
|
||||
}
|
||||
|
||||
func (rl *rateLogger) currentIntervalStart(now time.Time) time.Time {
|
||||
@@ -43,12 +45,12 @@ func (rl *rateLogger) currentIntervalStart(now time.Time) time.Time {
|
||||
return now.Add(-(time.Duration(millisSince)) * time.Millisecond)
|
||||
}
|
||||
|
||||
func (rl *rateLogger) update() {
|
||||
func (rl *rateLogger) update(numRoutes int64) {
|
||||
now := rl.now()
|
||||
periodEnd := rl.periodStart.Add(rl.interval)
|
||||
if periodEnd.Before(now) {
|
||||
if rl.periodCount != 0 {
|
||||
rl.callback(rl.periodCount, rl.periodStart)
|
||||
rl.callback(rl.periodCount, rl.periodStart, numRoutes)
|
||||
}
|
||||
rl.periodCount = 0
|
||||
rl.periodStart = rl.currentIntervalStart(now)
|
||||
@@ -56,7 +58,7 @@ func (rl *rateLogger) update() {
|
||||
rl.periodCount++
|
||||
}
|
||||
|
||||
func newRateLogger(now func() time.Time, interval time.Duration, callback func(int64, time.Time)) *rateLogger {
|
||||
func newRateLogger(now func() time.Time, interval time.Duration, callback func(int64, time.Time, int64)) *rateLogger {
|
||||
nowTime := now()
|
||||
return &rateLogger{
|
||||
callback: callback,
|
||||
@@ -78,6 +80,42 @@ type RouteAdvertiser interface {
|
||||
UnadvertiseRoute(...netip.Prefix) error
|
||||
}
|
||||
|
||||
var (
|
||||
metricStoreRoutesRateBuckets = []int64{1, 2, 3, 4, 5, 10, 100, 1000}
|
||||
metricStoreRoutesNBuckets = []int64{1, 2, 3, 4, 5, 10, 100, 1000, 10000}
|
||||
metricStoreRoutesRate []*clientmetric.Metric
|
||||
metricStoreRoutesN []*clientmetric.Metric
|
||||
)
|
||||
|
||||
func initMetricStoreRoutes() {
|
||||
for _, n := range metricStoreRoutesRateBuckets {
|
||||
metricStoreRoutesRate = append(metricStoreRoutesRate, clientmetric.NewCounter(fmt.Sprintf("appc_store_routes_rate_%d", n)))
|
||||
}
|
||||
metricStoreRoutesRate = append(metricStoreRoutesRate, clientmetric.NewCounter("appc_store_routes_rate_over"))
|
||||
for _, n := range metricStoreRoutesNBuckets {
|
||||
metricStoreRoutesN = append(metricStoreRoutesN, clientmetric.NewCounter(fmt.Sprintf("appc_store_routes_n_routes_%d", n)))
|
||||
}
|
||||
metricStoreRoutesN = append(metricStoreRoutesN, clientmetric.NewCounter("appc_store_routes_n_routes_over"))
|
||||
}
|
||||
|
||||
func recordMetric(val int64, buckets []int64, metrics []*clientmetric.Metric) {
|
||||
if len(buckets) < 1 {
|
||||
return
|
||||
}
|
||||
// finds the first bucket where val <=, or len(buckets) if none match
|
||||
// for bucket values of 1, 10, 100; 0-1 goes to [0], 2-10 goes to [1], 11-100 goes to [2], 101+ goes to [3]
|
||||
bucket, _ := slices.BinarySearch(buckets, val)
|
||||
metrics[bucket].Add(1)
|
||||
}
|
||||
|
||||
func metricStoreRoutes(rate, nRoutes int64) {
|
||||
if len(metricStoreRoutesRate) == 0 {
|
||||
initMetricStoreRoutes()
|
||||
}
|
||||
recordMetric(rate, metricStoreRoutesRateBuckets, metricStoreRoutesRate)
|
||||
recordMetric(nRoutes, metricStoreRoutesNBuckets, metricStoreRoutesN)
|
||||
}
|
||||
|
||||
// RouteInfo is a data structure used to persist the in memory state of an AppConnector
|
||||
// so that we can know, even after a restart, which routes came from ACLs and which were
|
||||
// learned from domains.
|
||||
@@ -139,11 +177,12 @@ func NewAppConnector(logf logger.Logf, routeAdvertiser RouteAdvertiser, routeInf
|
||||
ac.wildcards = routeInfo.Wildcards
|
||||
ac.controlRoutes = routeInfo.Control
|
||||
}
|
||||
ac.writeRateMinute = newRateLogger(time.Now, time.Minute, func(c int64, s time.Time) {
|
||||
ac.logf("routeInfo write rate: %d in minute starting at %v", c, s)
|
||||
ac.writeRateMinute = newRateLogger(time.Now, time.Minute, func(c int64, s time.Time, l int64) {
|
||||
ac.logf("routeInfo write rate: %d in minute starting at %v (%d routes)", c, s, l)
|
||||
metricStoreRoutes(c, l)
|
||||
})
|
||||
ac.writeRateDay = newRateLogger(time.Now, 24*time.Hour, func(c int64, s time.Time) {
|
||||
ac.logf("routeInfo write rate: %d in 24 hours starting at %v", c, s)
|
||||
ac.writeRateDay = newRateLogger(time.Now, 24*time.Hour, func(c int64, s time.Time, l int64) {
|
||||
ac.logf("routeInfo write rate: %d in 24 hours starting at %v (%d routes)", c, s, l)
|
||||
})
|
||||
return ac
|
||||
}
|
||||
@@ -159,8 +198,15 @@ func (e *AppConnector) storeRoutesLocked() error {
|
||||
if !e.ShouldStoreRoutes() {
|
||||
return nil
|
||||
}
|
||||
e.writeRateMinute.update()
|
||||
e.writeRateDay.update()
|
||||
|
||||
// log write rate and write size
|
||||
numRoutes := int64(len(e.controlRoutes))
|
||||
for _, rs := range e.domains {
|
||||
numRoutes += int64(len(rs))
|
||||
}
|
||||
e.writeRateMinute.update(numRoutes)
|
||||
e.writeRateDay.update(numRoutes)
|
||||
|
||||
return e.storeRoutesFunc(&RouteInfo{
|
||||
Control: e.controlRoutes,
|
||||
Domains: e.domains,
|
||||
@@ -435,8 +481,10 @@ func (e *AppConnector) ObserveDNSResponse(res []byte) {
|
||||
}
|
||||
}
|
||||
|
||||
e.logf("[v2] observed new routes for %s: %s", domain, toAdvertise)
|
||||
e.scheduleAdvertisement(domain, toAdvertise...)
|
||||
if len(toAdvertise) > 0 {
|
||||
e.logf("[v2] observed new routes for %s: %s", domain, toAdvertise)
|
||||
e.scheduleAdvertisement(domain, toAdvertise...)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"golang.org/x/net/dns/dnsmessage"
|
||||
"tailscale.com/appc/appctest"
|
||||
"tailscale.com/tstest"
|
||||
"tailscale.com/util/clientmetric"
|
||||
"tailscale.com/util/mak"
|
||||
"tailscale.com/util/must"
|
||||
)
|
||||
@@ -526,7 +527,7 @@ func TestRoutesWithout(t *testing.T) {
|
||||
func TestRateLogger(t *testing.T) {
|
||||
clock := tstest.Clock{}
|
||||
wasCalled := false
|
||||
rl := newRateLogger(func() time.Time { return clock.Now() }, 1*time.Second, func(count int64, _ time.Time) {
|
||||
rl := newRateLogger(func() time.Time { return clock.Now() }, 1*time.Second, func(count int64, _ time.Time, _ int64) {
|
||||
if count != 3 {
|
||||
t.Fatalf("count for prev period: got %d, want 3", count)
|
||||
}
|
||||
@@ -535,20 +536,20 @@ func TestRateLogger(t *testing.T) {
|
||||
|
||||
for i := 0; i < 3; i++ {
|
||||
clock.Advance(1 * time.Millisecond)
|
||||
rl.update()
|
||||
rl.update(0)
|
||||
if wasCalled {
|
||||
t.Fatalf("wasCalled: got true, want false")
|
||||
}
|
||||
}
|
||||
|
||||
clock.Advance(1 * time.Second)
|
||||
rl.update()
|
||||
rl.update(0)
|
||||
if !wasCalled {
|
||||
t.Fatalf("wasCalled: got false, want true")
|
||||
}
|
||||
|
||||
wasCalled = false
|
||||
rl = newRateLogger(func() time.Time { return clock.Now() }, 1*time.Hour, func(count int64, _ time.Time) {
|
||||
rl = newRateLogger(func() time.Time { return clock.Now() }, 1*time.Hour, func(count int64, _ time.Time, _ int64) {
|
||||
if count != 3 {
|
||||
t.Fatalf("count for prev period: got %d, want 3", count)
|
||||
}
|
||||
@@ -557,15 +558,47 @@ func TestRateLogger(t *testing.T) {
|
||||
|
||||
for i := 0; i < 3; i++ {
|
||||
clock.Advance(1 * time.Minute)
|
||||
rl.update()
|
||||
rl.update(0)
|
||||
if wasCalled {
|
||||
t.Fatalf("wasCalled: got true, want false")
|
||||
}
|
||||
}
|
||||
|
||||
clock.Advance(1 * time.Hour)
|
||||
rl.update()
|
||||
rl.update(0)
|
||||
if !wasCalled {
|
||||
t.Fatalf("wasCalled: got false, want true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRouteStoreMetrics(t *testing.T) {
|
||||
metricStoreRoutes(1, 1)
|
||||
metricStoreRoutes(1, 1) // the 1 buckets value should be 2
|
||||
metricStoreRoutes(5, 5) // the 5 buckets value should be 1
|
||||
metricStoreRoutes(6, 6) // the 10 buckets value should be 1
|
||||
metricStoreRoutes(10001, 10001) // the over buckets value should be 1
|
||||
wanted := map[string]int64{
|
||||
"appc_store_routes_n_routes_1": 2,
|
||||
"appc_store_routes_rate_1": 2,
|
||||
"appc_store_routes_n_routes_5": 1,
|
||||
"appc_store_routes_rate_5": 1,
|
||||
"appc_store_routes_n_routes_10": 1,
|
||||
"appc_store_routes_rate_10": 1,
|
||||
"appc_store_routes_n_routes_over": 1,
|
||||
"appc_store_routes_rate_over": 1,
|
||||
}
|
||||
for _, x := range clientmetric.Metrics() {
|
||||
if x.Value() != wanted[x.Name()] {
|
||||
t.Errorf("%s: want: %d, got: %d", x.Name(), wanted[x.Name()], x.Value())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMetricBucketsAreSorted(t *testing.T) {
|
||||
if !slices.IsSorted(metricStoreRoutesRateBuckets) {
|
||||
t.Errorf("metricStoreRoutesRateBuckets must be in order")
|
||||
}
|
||||
if !slices.IsSorted(metricStoreRoutesNBuckets) {
|
||||
t.Errorf("metricStoreRoutesNBuckets must be in order")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
// Package appctest contains code to help test App Connectors.
|
||||
package appctest
|
||||
|
||||
import (
|
||||
|
||||
27
assert_ts_toolchain_match.go
Normal file
27
assert_ts_toolchain_match.go
Normal file
@@ -0,0 +1,27 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
//go:build tailscale_go
|
||||
|
||||
package tailscaleroot
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func init() {
|
||||
tsRev, ok := tailscaleToolchainRev()
|
||||
if !ok {
|
||||
panic("binary built with tailscale_go build tag but failed to read build info or find tailscale.toolchain.rev in build info")
|
||||
}
|
||||
want := strings.TrimSpace(GoToolchainRev)
|
||||
if tsRev != want {
|
||||
if os.Getenv("TS_PERMIT_TOOLCHAIN_MISMATCH") == "1" {
|
||||
fmt.Fprintf(os.Stderr, "tailscale.toolchain.rev = %q, want %q; but ignoring due to TS_PERMIT_TOOLCHAIN_MISMATCH=1\n", tsRev, want)
|
||||
return
|
||||
}
|
||||
panic(fmt.Sprintf("binary built with tailscale_go build tag but Go toolchain %q doesn't match github.com/tailscale/tailscale expected value %q; override this failure with TS_PERMIT_TOOLCHAIN_MISMATCH=1", tsRev, want))
|
||||
}
|
||||
}
|
||||
@@ -1,21 +1,11 @@
|
||||
#!/usr/bin/env sh
|
||||
|
||||
#
|
||||
# Runs `go build` with flags configured for docker distribution. All
|
||||
# it does differently from `go build` is burn git commit and version
|
||||
# information into the binaries inside docker, so that we can track down user
|
||||
# issues.
|
||||
#
|
||||
############################################################################
|
||||
#
|
||||
# WARNING: Tailscale is not yet officially supported in container
|
||||
# environments, such as Docker and Kubernetes. Though it should work, we
|
||||
# don't regularly test it, and we know there are some feature limitations.
|
||||
#
|
||||
# See current bugs tagged "containers":
|
||||
# https://github.com/tailscale/tailscale/labels/containers
|
||||
#
|
||||
############################################################################
|
||||
# This script builds Tailscale container images using
|
||||
# github.com/tailscale/mkctr.
|
||||
# By default the images will be tagged with the current version and git
|
||||
# hash of this repository as produced by ./cmd/mkversion.
|
||||
# This is the image build mechanim used to build the official Tailscale
|
||||
# container images.
|
||||
|
||||
set -eu
|
||||
|
||||
@@ -27,12 +17,20 @@ eval "$(./build_dist.sh shellvars)"
|
||||
DEFAULT_TARGET="client"
|
||||
DEFAULT_TAGS="v${VERSION_SHORT},v${VERSION_MINOR}"
|
||||
DEFAULT_BASE="tailscale/alpine-base:3.18"
|
||||
# Set a few pre-defined OCI annotations. The source annotation is used by tools such as Renovate that scan the linked
|
||||
# Github repo to find release notes for any new image tags. Note that for official Tailscale images the default
|
||||
# annotations defined here will be overriden by release scripts that call this script.
|
||||
# https://github.com/opencontainers/image-spec/blob/main/annotations.md#pre-defined-annotation-keys
|
||||
DEFAULT_ANNOTATIONS="org.opencontainers.image.source=https://github.com/tailscale/tailscale/blob/main/build_docker.sh,org.opencontainers.image.vendor=Tailscale"
|
||||
|
||||
PUSH="${PUSH:-false}"
|
||||
TARGET="${TARGET:-${DEFAULT_TARGET}}"
|
||||
TAGS="${TAGS:-${DEFAULT_TAGS}}"
|
||||
BASE="${BASE:-${DEFAULT_BASE}}"
|
||||
PLATFORM="${PLATFORM:-}" # default to all platforms
|
||||
# OCI annotations that will be added to the image.
|
||||
# https://github.com/opencontainers/image-spec/blob/main/annotations.md
|
||||
ANNOTATIONS="${ANNOTATIONS:-${DEFAULT_ANNOTATIONS}}"
|
||||
|
||||
case "$TARGET" in
|
||||
client)
|
||||
@@ -49,10 +47,11 @@ case "$TARGET" in
|
||||
-X tailscale.com/version.gitCommitStamp=${VERSION_GIT_HASH}" \
|
||||
--base="${BASE}" \
|
||||
--tags="${TAGS}" \
|
||||
--gotags="ts_kube" \
|
||||
--gotags="ts_kube,ts_package_container" \
|
||||
--repos="${REPOS}" \
|
||||
--push="${PUSH}" \
|
||||
--target="${PLATFORM}" \
|
||||
--annotations="${ANNOTATIONS}" \
|
||||
/usr/local/bin/containerboot
|
||||
;;
|
||||
operator)
|
||||
@@ -66,9 +65,11 @@ case "$TARGET" in
|
||||
-X tailscale.com/version.gitCommitStamp=${VERSION_GIT_HASH}" \
|
||||
--base="${BASE}" \
|
||||
--tags="${TAGS}" \
|
||||
--gotags="ts_kube,ts_package_container" \
|
||||
--repos="${REPOS}" \
|
||||
--push="${PUSH}" \
|
||||
--target="${PLATFORM}" \
|
||||
--annotations="${ANNOTATIONS}" \
|
||||
/usr/local/bin/operator
|
||||
;;
|
||||
k8s-nameserver)
|
||||
@@ -82,9 +83,11 @@ case "$TARGET" in
|
||||
-X tailscale.com/version.gitCommitStamp=${VERSION_GIT_HASH}" \
|
||||
--base="${BASE}" \
|
||||
--tags="${TAGS}" \
|
||||
--gotags="ts_kube,ts_package_container" \
|
||||
--repos="${REPOS}" \
|
||||
--push="${PUSH}" \
|
||||
--target="${PLATFORM}" \
|
||||
--annotations="${ANNOTATIONS}" \
|
||||
/usr/local/bin/k8s-nameserver
|
||||
;;
|
||||
*)
|
||||
|
||||
@@ -19,6 +19,7 @@ import (
|
||||
// Only one of Src/Dst or Users/Ports may be specified.
|
||||
type ACLRow struct {
|
||||
Action string `json:"action,omitempty"` // valid values: "accept"
|
||||
Proto string `json:"proto,omitempty"` // protocol
|
||||
Users []string `json:"users,omitempty"` // old name for src
|
||||
Ports []string `json:"ports,omitempty"` // old name for dst
|
||||
Src []string `json:"src,omitempty"`
|
||||
@@ -31,12 +32,23 @@ type ACLRow struct {
|
||||
type ACLTest struct {
|
||||
Src string `json:"src,omitempty"` // source
|
||||
User string `json:"user,omitempty"` // old name for source
|
||||
Proto string `json:"proto,omitempty"` // protocol
|
||||
Accept []string `json:"accept,omitempty"` // expected destination ip:port that user can access
|
||||
Deny []string `json:"deny,omitempty"` // expected destination ip:port that user cannot access
|
||||
|
||||
Allow []string `json:"allow,omitempty"` // old name for accept
|
||||
}
|
||||
|
||||
// NodeAttrGrant defines additional string attributes that apply to specific devices.
|
||||
type NodeAttrGrant struct {
|
||||
// Target specifies which nodes the attributes apply to. The nodes can be a
|
||||
// tag (tag:server), user (alice@example.com), group (group:kids), or *.
|
||||
Target []string `json:"target,omitempty"`
|
||||
|
||||
// Attr are the attributes to set on Target(s).
|
||||
Attr []string `json:"attr,omitempty"`
|
||||
}
|
||||
|
||||
// ACLDetails contains all the details for an ACL.
|
||||
type ACLDetails struct {
|
||||
Tests []ACLTest `json:"tests,omitempty"`
|
||||
@@ -44,6 +56,7 @@ type ACLDetails struct {
|
||||
Groups map[string][]string `json:"groups,omitempty"`
|
||||
TagOwners map[string][]string `json:"tagowners,omitempty"`
|
||||
Hosts map[string]string `json:"hosts,omitempty"`
|
||||
NodeAttrs []NodeAttrGrant `json:"nodeAttrs,omitempty"`
|
||||
}
|
||||
|
||||
// ACL contains an ACLDetails and metadata.
|
||||
@@ -150,7 +163,12 @@ func (c *Client) ACLHuJSON(ctx context.Context) (acl *ACLHuJSON, err error) {
|
||||
// ACLTestFailureSummary specifies the JSON format sent to the
|
||||
// JavaScript client to be rendered in the HTML.
|
||||
type ACLTestFailureSummary struct {
|
||||
User string `json:"user,omitempty"`
|
||||
// User is the source ("src") value of the ACL test that failed.
|
||||
// The name "user" is a legacy holdover from the original naming and
|
||||
// is kept for compatibility but it may also contain any value
|
||||
// that's valid in a ACL test "src" field.
|
||||
User string `json:"user,omitempty"`
|
||||
|
||||
Errors []string `json:"errors,omitempty"`
|
||||
Warnings []string `json:"warnings,omitempty"`
|
||||
}
|
||||
@@ -270,6 +288,9 @@ type UserRuleMatch struct {
|
||||
Users []string `json:"users"`
|
||||
Ports []string `json:"ports"`
|
||||
LineNumber int `json:"lineNumber"`
|
||||
// Via is the list of targets through which Users can access Ports.
|
||||
// See https://tailscale.com/kb/1378/via for more information.
|
||||
Via []string `json:"via,omitempty"`
|
||||
|
||||
// Postures is a list of posture policies that are
|
||||
// associated with this match. The rules can be looked
|
||||
|
||||
@@ -4,7 +4,10 @@
|
||||
// Package apitype contains types for the Tailscale LocalAPI and control plane API.
|
||||
package apitype
|
||||
|
||||
import "tailscale.com/tailcfg"
|
||||
import (
|
||||
"tailscale.com/tailcfg"
|
||||
"tailscale.com/types/dnstype"
|
||||
)
|
||||
|
||||
// LocalAPIHost is the Host header value used by the LocalAPI.
|
||||
const LocalAPIHost = "local-tailscaled.sock"
|
||||
@@ -57,3 +60,19 @@ type ExitNodeSuggestionResponse struct {
|
||||
Name string
|
||||
Location tailcfg.LocationView `json:",omitempty"`
|
||||
}
|
||||
|
||||
// DNSOSConfig mimics dns.OSConfig without forcing us to import the entire dns package
|
||||
// into the CLI.
|
||||
type DNSOSConfig struct {
|
||||
Nameservers []string
|
||||
SearchDomains []string
|
||||
MatchDomains []string
|
||||
}
|
||||
|
||||
// DNSQueryResponse is the response to a DNS query request sent via LocalAPI.
|
||||
type DNSQueryResponse struct {
|
||||
// Bytes is the raw DNS response bytes.
|
||||
Bytes []byte
|
||||
// Resolvers is the list of resolvers that the forwarder deemed able to resolve the query.
|
||||
Resolvers []*dnstype.Resolver
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
@@ -39,6 +40,7 @@ type Device struct {
|
||||
// It's currently just 1 element, the 100.x.y.z Tailscale IP.
|
||||
Addresses []string `json:"addresses"`
|
||||
DeviceID string `json:"id"`
|
||||
NodeID string `json:"nodeId"`
|
||||
User string `json:"user"`
|
||||
Name string `json:"name"`
|
||||
Hostname string `json:"hostname"`
|
||||
@@ -213,6 +215,9 @@ func (c *Client) DeleteDevice(ctx context.Context, deviceID string) (err error)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
log.Printf("RESP: %di, path: %s", resp.StatusCode, path)
|
||||
|
||||
// If status code was not successful, return the error.
|
||||
// TODO: Change the check for the StatusCode to include other 2XX success codes.
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
|
||||
@@ -37,8 +37,10 @@ import (
|
||||
"tailscale.com/safesocket"
|
||||
"tailscale.com/tailcfg"
|
||||
"tailscale.com/tka"
|
||||
"tailscale.com/types/dnstype"
|
||||
"tailscale.com/types/key"
|
||||
"tailscale.com/types/tkatype"
|
||||
"tailscale.com/util/syspolicy/setting"
|
||||
)
|
||||
|
||||
// defaultLocalClient is the default LocalClient when using the legacy
|
||||
@@ -69,6 +71,14 @@ type LocalClient struct {
|
||||
// connecting to the GUI client variants.
|
||||
UseSocketOnly bool
|
||||
|
||||
// OmitAuth, if true, omits sending the local Tailscale daemon any
|
||||
// authentication token that might be required by the platform.
|
||||
//
|
||||
// As of 2024-08-12, only macOS uses an authentication token. OmitAuth is
|
||||
// meant for when Dial is set and the LocalAPI is being proxied to a
|
||||
// different operating system, such as in integration tests.
|
||||
OmitAuth bool
|
||||
|
||||
// tsClient does HTTP requests to the local Tailscale daemon.
|
||||
// It's lazily initialized on first use.
|
||||
tsClient *http.Client
|
||||
@@ -103,7 +113,7 @@ func (lc *LocalClient) defaultDialer(ctx context.Context, network, addr string)
|
||||
return d.DialContext(ctx, "tcp", "127.0.0.1:"+strconv.Itoa(port))
|
||||
}
|
||||
}
|
||||
return safesocket.Connect(lc.socket())
|
||||
return safesocket.ConnectContext(ctx, lc.socket())
|
||||
}
|
||||
|
||||
// DoLocalRequest makes an HTTP request to the local machine's Tailscale daemon.
|
||||
@@ -124,8 +134,10 @@ func (lc *LocalClient) DoLocalRequest(req *http.Request) (*http.Response, error)
|
||||
},
|
||||
}
|
||||
})
|
||||
if _, token, err := safesocket.LocalTCPPortAndToken(); err == nil {
|
||||
req.SetBasicAuth("", token)
|
||||
if !lc.OmitAuth {
|
||||
if _, token, err := safesocket.LocalTCPPortAndToken(); err == nil {
|
||||
req.SetBasicAuth("", token)
|
||||
}
|
||||
}
|
||||
return lc.tsClient.Do(req)
|
||||
}
|
||||
@@ -253,11 +265,16 @@ func (lc *LocalClient) sendWithHeaders(
|
||||
}
|
||||
if res.StatusCode != wantStatus {
|
||||
err = fmt.Errorf("%v: %s", res.Status, bytes.TrimSpace(slurp))
|
||||
return nil, nil, bestError(err, slurp)
|
||||
return nil, nil, httpStatusError{bestError(err, slurp), res.StatusCode}
|
||||
}
|
||||
return slurp, res.Header, nil
|
||||
}
|
||||
|
||||
type httpStatusError struct {
|
||||
error
|
||||
HTTPStatus int
|
||||
}
|
||||
|
||||
func (lc *LocalClient) get200(ctx context.Context, path string) ([]byte, error) {
|
||||
return lc.send(ctx, "GET", path, 200, nil)
|
||||
}
|
||||
@@ -278,9 +295,50 @@ func decodeJSON[T any](b []byte) (ret T, err error) {
|
||||
}
|
||||
|
||||
// WhoIs returns the owner of the remoteAddr, which must be an IP or IP:port.
|
||||
//
|
||||
// If not found, the error is ErrPeerNotFound.
|
||||
//
|
||||
// For connections proxied by tailscaled, this looks up the owner of the given
|
||||
// address as TCP first, falling back to UDP; if you want to only check a
|
||||
// specific address family, use WhoIsProto.
|
||||
func (lc *LocalClient) WhoIs(ctx context.Context, remoteAddr string) (*apitype.WhoIsResponse, error) {
|
||||
body, err := lc.get200(ctx, "/localapi/v0/whois?addr="+url.QueryEscape(remoteAddr))
|
||||
if err != nil {
|
||||
if hs, ok := err.(httpStatusError); ok && hs.HTTPStatus == http.StatusNotFound {
|
||||
return nil, ErrPeerNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return decodeJSON[*apitype.WhoIsResponse](body)
|
||||
}
|
||||
|
||||
// ErrPeerNotFound is returned by WhoIs and WhoIsNodeKey when a peer is not found.
|
||||
var ErrPeerNotFound = errors.New("peer not found")
|
||||
|
||||
// WhoIsNodeKey returns the owner of the given wireguard public key.
|
||||
//
|
||||
// If not found, the error is ErrPeerNotFound.
|
||||
func (lc *LocalClient) WhoIsNodeKey(ctx context.Context, key key.NodePublic) (*apitype.WhoIsResponse, error) {
|
||||
body, err := lc.get200(ctx, "/localapi/v0/whois?addr="+url.QueryEscape(key.String()))
|
||||
if err != nil {
|
||||
if hs, ok := err.(httpStatusError); ok && hs.HTTPStatus == http.StatusNotFound {
|
||||
return nil, ErrPeerNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return decodeJSON[*apitype.WhoIsResponse](body)
|
||||
}
|
||||
|
||||
// WhoIsProto returns the owner of the remoteAddr, which must be an IP or
|
||||
// IP:port, for the given protocol (tcp or udp).
|
||||
//
|
||||
// If not found, the error is ErrPeerNotFound.
|
||||
func (lc *LocalClient) WhoIsProto(ctx context.Context, proto, remoteAddr string) (*apitype.WhoIsResponse, error) {
|
||||
body, err := lc.get200(ctx, "/localapi/v0/whois?proto="+url.QueryEscape(proto)+"&addr="+url.QueryEscape(remoteAddr))
|
||||
if err != nil {
|
||||
if hs, ok := err.(httpStatusError); ok && hs.HTTPStatus == http.StatusNotFound {
|
||||
return nil, ErrPeerNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return decodeJSON[*apitype.WhoIsResponse](body)
|
||||
@@ -297,6 +355,12 @@ func (lc *LocalClient) DaemonMetrics(ctx context.Context) ([]byte, error) {
|
||||
return lc.get200(ctx, "/localapi/v0/metrics")
|
||||
}
|
||||
|
||||
// UserMetrics returns the user metrics in
|
||||
// the Prometheus text exposition format.
|
||||
func (lc *LocalClient) UserMetrics(ctx context.Context) ([]byte, error) {
|
||||
return lc.get200(ctx, "/localapi/v0/usermetrics")
|
||||
}
|
||||
|
||||
// IncrementCounter increments the value of a Tailscale daemon's counter
|
||||
// metric by the given delta. If the metric has yet to exist, a new counter
|
||||
// metric is created and initialized to delta.
|
||||
@@ -699,6 +763,27 @@ func (lc *LocalClient) CheckUDPGROForwarding(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetUDPGROForwarding enables UDP GRO forwarding for the main interface of this
|
||||
// node. This can be done to improve performance of tailnet nodes acting as exit
|
||||
// nodes or subnet routers.
|
||||
// See https://tailscale.com/kb/1320/performance-best-practices#linux-optimizations-for-subnet-routers-and-exit-nodes
|
||||
func (lc *LocalClient) SetUDPGROForwarding(ctx context.Context) error {
|
||||
body, err := lc.get200(ctx, "/localapi/v0/set-udp-gro-forwarding")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var jres struct {
|
||||
Warning string
|
||||
}
|
||||
if err := json.Unmarshal(body, &jres); err != nil {
|
||||
return fmt.Errorf("invalid JSON from set-udp-gro-forwarding: %w", err)
|
||||
}
|
||||
if jres.Warning != "" {
|
||||
return errors.New(jres.Warning)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CheckPrefs validates the provided preferences, without making any changes.
|
||||
//
|
||||
// The CLI uses this before a Start call to fail fast if the preferences won't
|
||||
@@ -730,6 +815,62 @@ func (lc *LocalClient) EditPrefs(ctx context.Context, mp *ipn.MaskedPrefs) (*ipn
|
||||
return decodeJSON[*ipn.Prefs](body)
|
||||
}
|
||||
|
||||
// GetEffectivePolicy returns the effective policy for the specified scope.
|
||||
func (lc *LocalClient) GetEffectivePolicy(ctx context.Context, scope setting.PolicyScope) (*setting.Snapshot, error) {
|
||||
scopeID, err := scope.MarshalText()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
body, err := lc.get200(ctx, "/localapi/v0/policy/"+string(scopeID))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return decodeJSON[*setting.Snapshot](body)
|
||||
}
|
||||
|
||||
// ReloadEffectivePolicy reloads the effective policy for the specified scope
|
||||
// by reading and merging policy settings from all applicable policy sources.
|
||||
func (lc *LocalClient) ReloadEffectivePolicy(ctx context.Context, scope setting.PolicyScope) (*setting.Snapshot, error) {
|
||||
scopeID, err := scope.MarshalText()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
body, err := lc.send(ctx, "POST", "/localapi/v0/policy/"+string(scopeID), 200, http.NoBody)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return decodeJSON[*setting.Snapshot](body)
|
||||
}
|
||||
|
||||
// GetDNSOSConfig returns the system DNS configuration for the current device.
|
||||
// That is, it returns the DNS configuration that the system would use if Tailscale weren't being used.
|
||||
func (lc *LocalClient) GetDNSOSConfig(ctx context.Context) (*apitype.DNSOSConfig, error) {
|
||||
body, err := lc.get200(ctx, "/localapi/v0/dns-osconfig")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var osCfg apitype.DNSOSConfig
|
||||
if err := json.Unmarshal(body, &osCfg); err != nil {
|
||||
return nil, fmt.Errorf("invalid dns.OSConfig: %w", err)
|
||||
}
|
||||
return &osCfg, nil
|
||||
}
|
||||
|
||||
// QueryDNS executes a DNS query for a name (`google.com.`) and query type (`CNAME`).
|
||||
// It returns the raw DNS response bytes and the resolvers that were used to answer the query
|
||||
// (often just one, but can be more if we raced multiple resolvers).
|
||||
func (lc *LocalClient) QueryDNS(ctx context.Context, name string, queryType string) (bytes []byte, resolvers []*dnstype.Resolver, err error) {
|
||||
body, err := lc.get200(ctx, fmt.Sprintf("/localapi/v0/dns-query?name=%s&type=%s", url.QueryEscape(name), queryType))
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
var res apitype.DNSQueryResponse
|
||||
if err := json.Unmarshal(body, &res); err != nil {
|
||||
return nil, nil, fmt.Errorf("invalid query response: %w", err)
|
||||
}
|
||||
return res.Bytes, res.Resolvers, nil
|
||||
}
|
||||
|
||||
// StartLoginInteractive starts an interactive login.
|
||||
func (lc *LocalClient) StartLoginInteractive(ctx context.Context) error {
|
||||
_, err := lc.send(ctx, "POST", "/localapi/v0/login-interactive", http.StatusNoContent, nil)
|
||||
@@ -866,7 +1007,20 @@ func CertPair(ctx context.Context, domain string) (certPEM, keyPEM []byte, err e
|
||||
//
|
||||
// API maturity: this is considered a stable API.
|
||||
func (lc *LocalClient) CertPair(ctx context.Context, domain string) (certPEM, keyPEM []byte, err error) {
|
||||
res, err := lc.send(ctx, "GET", "/localapi/v0/cert/"+domain+"?type=pair", 200, nil)
|
||||
return lc.CertPairWithValidity(ctx, domain, 0)
|
||||
}
|
||||
|
||||
// CertPairWithValidity returns a cert and private key for the provided DNS
|
||||
// domain.
|
||||
//
|
||||
// It returns a cached certificate from disk if it's still valid.
|
||||
// When minValidity is non-zero, the returned certificate will be valid for at
|
||||
// least the given duration, if permitted by the CA. If the certificate is
|
||||
// valid, but for less than minValidity, it will be synchronously renewed.
|
||||
//
|
||||
// API maturity: this is considered a stable API.
|
||||
func (lc *LocalClient) CertPairWithValidity(ctx context.Context, domain string, minValidity time.Duration) (certPEM, keyPEM []byte, err error) {
|
||||
res, err := lc.send(ctx, "GET", fmt.Sprintf("/localapi/v0/cert/%s?type=pair&min_validity=%s", domain, minValidity), 200, nil)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
@@ -1173,6 +1327,17 @@ func (lc *LocalClient) SetServeConfig(ctx context.Context, config *ipn.ServeConf
|
||||
return nil
|
||||
}
|
||||
|
||||
// DisconnectControl shuts down all connections to control, thus making control consider this node inactive. This can be
|
||||
// run on HA subnet router or app connector replicas before shutting them down to ensure peers get told to switch over
|
||||
// to another replica whilst there is still some grace period for the existing connections to terminate.
|
||||
func (lc *LocalClient) DisconnectControl(ctx context.Context) error {
|
||||
_, _, err := lc.sendWithHeaders(ctx, "POST", "/localapi/v0/disconnect-control", 200, nil, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error disconnecting control: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// NetworkLockDisable shuts down network-lock across the tailnet.
|
||||
func (lc *LocalClient) NetworkLockDisable(ctx context.Context, secret []byte) error {
|
||||
if _, err := lc.send(ctx, "POST", "/localapi/v0/tka/disable", 200, bytes.NewReader(secret)); err != nil {
|
||||
|
||||
@@ -6,9 +6,14 @@
|
||||
package tailscale
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"tailscale.com/tstest/deptest"
|
||||
"tailscale.com/types/key"
|
||||
)
|
||||
|
||||
func TestGetServeConfigFromJSON(t *testing.T) {
|
||||
@@ -30,6 +35,32 @@ func TestGetServeConfigFromJSON(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestWhoIsPeerNotFound(t *testing.T) {
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(404)
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
lc := &LocalClient{
|
||||
Dial: func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
var std net.Dialer
|
||||
return std.DialContext(ctx, network, ts.Listener.Addr().(*net.TCPAddr).String())
|
||||
},
|
||||
}
|
||||
var k key.NodePublic
|
||||
if err := k.UnmarshalText([]byte("nodekey:5c8f86d5fc70d924e55f02446165a5dae8f822994ad26bcf4b08fd841f9bf261")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
res, err := lc.WhoIsNodeKey(context.Background(), k)
|
||||
if err != ErrPeerNotFound {
|
||||
t.Errorf("got (%v, %v), want ErrPeerNotFound", res, err)
|
||||
}
|
||||
res, err = lc.WhoIs(context.Background(), "1.2.3.4:5678")
|
||||
if err != ErrPeerNotFound {
|
||||
t.Errorf("got (%v, %v), want ErrPeerNotFound", res, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeps(t *testing.T) {
|
||||
deptest.DepChecker{
|
||||
BadDeps: map[string]string{
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
//go:build !go1.21
|
||||
//go:build !go1.23
|
||||
|
||||
package tailscale
|
||||
|
||||
func init() {
|
||||
you_need_Go_1_21_to_compile_Tailscale()
|
||||
you_need_Go_1_23_to_compile_Tailscale()
|
||||
}
|
||||
|
||||
@@ -51,6 +51,9 @@ type Client struct {
|
||||
// HTTPClient optionally specifies an alternate HTTP client to use.
|
||||
// If nil, http.DefaultClient is used.
|
||||
HTTPClient *http.Client
|
||||
|
||||
// UserAgent optionally specifies an alternate User-Agent header
|
||||
UserAgent string
|
||||
}
|
||||
|
||||
func (c *Client) httpClient() *http.Client {
|
||||
@@ -97,8 +100,9 @@ func (c *Client) setAuth(r *http.Request) {
|
||||
// and can be changed manually by the user.
|
||||
func NewClient(tailnet string, auth AuthMethod) *Client {
|
||||
return &Client{
|
||||
tailnet: tailnet,
|
||||
auth: auth,
|
||||
tailnet: tailnet,
|
||||
auth: auth,
|
||||
UserAgent: "tailscale-client-oss",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -110,17 +114,16 @@ func (c *Client) Do(req *http.Request) (*http.Response, error) {
|
||||
return nil, errors.New("use of Client without setting I_Acknowledge_This_API_Is_Unstable")
|
||||
}
|
||||
c.setAuth(req)
|
||||
if c.UserAgent != "" {
|
||||
req.Header.Set("User-Agent", c.UserAgent)
|
||||
}
|
||||
return c.httpClient().Do(req)
|
||||
}
|
||||
|
||||
// sendRequest add the authentication key to the request and sends it. It
|
||||
// receives the response and reads up to 10MB of it.
|
||||
func (c *Client) sendRequest(req *http.Request) ([]byte, *http.Response, error) {
|
||||
if !I_Acknowledge_This_API_Is_Unstable {
|
||||
return nil, nil, errors.New("use of Client without setting I_Acknowledge_This_API_Is_Unstable")
|
||||
}
|
||||
c.setAuth(req)
|
||||
resp, err := c.httpClient().Do(req)
|
||||
resp, err := c.Do(req)
|
||||
if err != nil {
|
||||
return nil, resp, err
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"version": "0.0.1",
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": "18.16.1",
|
||||
"node": "18.20.4",
|
||||
"yarn": "1.22.19"
|
||||
},
|
||||
"type": "module",
|
||||
|
||||
@@ -17,7 +17,6 @@ import (
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -27,6 +26,7 @@ import (
|
||||
"tailscale.com/client/tailscale/apitype"
|
||||
"tailscale.com/clientupdate"
|
||||
"tailscale.com/envknob"
|
||||
"tailscale.com/envknob/featureknob"
|
||||
"tailscale.com/hostinfo"
|
||||
"tailscale.com/ipn"
|
||||
"tailscale.com/ipn/ipnstate"
|
||||
@@ -35,6 +35,7 @@ import (
|
||||
"tailscale.com/net/tsaddr"
|
||||
"tailscale.com/tailcfg"
|
||||
"tailscale.com/types/logger"
|
||||
"tailscale.com/types/views"
|
||||
"tailscale.com/util/httpm"
|
||||
"tailscale.com/version"
|
||||
"tailscale.com/version/distro"
|
||||
@@ -113,11 +114,6 @@ const (
|
||||
ManageServerMode ServerMode = "manage"
|
||||
)
|
||||
|
||||
var (
|
||||
exitNodeRouteV4 = netip.MustParsePrefix("0.0.0.0/0")
|
||||
exitNodeRouteV6 = netip.MustParsePrefix("::/0")
|
||||
)
|
||||
|
||||
// ServerOpts contains options for constructing a new Server.
|
||||
type ServerOpts struct {
|
||||
// Mode specifies the mode of web client being constructed.
|
||||
@@ -283,6 +279,12 @@ func (s *Server) serve(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
if r.URL.Path == "/metrics" {
|
||||
r.URL.Path = "/api/local/v0/usermetrics"
|
||||
s.proxyRequestToLocalAPI(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
if strings.HasPrefix(r.URL.Path, "/api/") {
|
||||
switch {
|
||||
case r.URL.Path == "/api/auth" && r.Method == httpm.GET:
|
||||
@@ -921,10 +923,10 @@ func (s *Server) serveGetNodeData(w http.ResponseWriter, r *http.Request) {
|
||||
return p == route
|
||||
})
|
||||
}
|
||||
data.AdvertisingExitNodeApproved = routeApproved(exitNodeRouteV4) || routeApproved(exitNodeRouteV6)
|
||||
data.AdvertisingExitNodeApproved = routeApproved(tsaddr.AllIPv4()) || routeApproved(tsaddr.AllIPv6())
|
||||
|
||||
for _, r := range prefs.AdvertiseRoutes {
|
||||
if r == exitNodeRouteV4 || r == exitNodeRouteV6 {
|
||||
if tsaddr.IsExitRoute(r) {
|
||||
data.AdvertisingExitNode = true
|
||||
} else {
|
||||
data.AdvertisedRoutes = append(data.AdvertisedRoutes, subnetRoute{
|
||||
@@ -959,37 +961,16 @@ func (s *Server) serveGetNodeData(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func availableFeatures() map[string]bool {
|
||||
env := hostinfo.GetEnvType()
|
||||
features := map[string]bool{
|
||||
"advertise-exit-node": true, // available on all platforms
|
||||
"advertise-routes": true, // available on all platforms
|
||||
"use-exit-node": canUseExitNode(env) == nil,
|
||||
"ssh": envknob.CanRunTailscaleSSH() == nil,
|
||||
"use-exit-node": featureknob.CanUseExitNode() == nil,
|
||||
"ssh": featureknob.CanRunTailscaleSSH() == nil,
|
||||
"auto-update": version.IsUnstableBuild() && clientupdate.CanAutoUpdate(),
|
||||
}
|
||||
if env == hostinfo.HomeAssistantAddOn {
|
||||
// Setting SSH on Home Assistant causes trouble on startup
|
||||
// (since the flag is not being passed to `tailscale up`).
|
||||
// Although Tailscale SSH does work here,
|
||||
// it's not terribly useful since it's running in a separate container.
|
||||
features["ssh"] = false
|
||||
}
|
||||
return features
|
||||
}
|
||||
|
||||
func canUseExitNode(env hostinfo.EnvType) error {
|
||||
switch dist := distro.Get(); dist {
|
||||
case distro.Synology, // see https://github.com/tailscale/tailscale/issues/1995
|
||||
distro.QNAP,
|
||||
distro.Unraid:
|
||||
return fmt.Errorf("Tailscale exit nodes cannot be used on %s.", dist)
|
||||
}
|
||||
if env == hostinfo.HomeAssistantAddOn {
|
||||
return errors.New("Tailscale exit nodes cannot be used on Home Assistant.")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// aclsAllowAccess returns whether tailnet ACLs (as expressed in the provided filter rules)
|
||||
// permit any devices to access the local web client.
|
||||
// This does not currently check whether a specific device can connect, just any device.
|
||||
@@ -1065,7 +1046,7 @@ func (s *Server) servePostRoutes(ctx context.Context, data postRoutesRequest) er
|
||||
var currNonExitRoutes []string
|
||||
var currAdvertisingExitNode bool
|
||||
for _, r := range prefs.AdvertiseRoutes {
|
||||
if r == exitNodeRouteV4 || r == exitNodeRouteV6 {
|
||||
if tsaddr.IsExitRoute(r) {
|
||||
currAdvertisingExitNode = true
|
||||
continue
|
||||
}
|
||||
@@ -1086,12 +1067,7 @@ func (s *Server) servePostRoutes(ctx context.Context, data postRoutesRequest) er
|
||||
return err
|
||||
}
|
||||
|
||||
hasExitNodeRoute := func(all []netip.Prefix) bool {
|
||||
return slices.Contains(all, exitNodeRouteV4) ||
|
||||
slices.Contains(all, exitNodeRouteV6)
|
||||
}
|
||||
|
||||
if !data.UseExitNode.IsZero() && hasExitNodeRoute(routes) {
|
||||
if !data.UseExitNode.IsZero() && tsaddr.ContainsExitRoutes(views.SliceOf(routes)) {
|
||||
return errors.New("cannot use and advertise exit node at same time")
|
||||
}
|
||||
|
||||
|
||||
@@ -5382,9 +5382,9 @@ wrappy@1:
|
||||
integrity sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==
|
||||
|
||||
ws@^8.14.2:
|
||||
version "8.14.2"
|
||||
resolved "https://registry.yarnpkg.com/ws/-/ws-8.14.2.tgz#6c249a806eb2db7a20d26d51e7709eab7b2e6c7f"
|
||||
integrity sha512-wEBG1ftX4jcglPxgFCMJmZ2PLtSbJ2Peg6TmpJFTbe9GZYOQCDPdMYu/Tm0/bGZkw8paZnJY45J4K2PZrLYq8g==
|
||||
version "8.17.1"
|
||||
resolved "https://registry.yarnpkg.com/ws/-/ws-8.17.1.tgz#9293da530bb548febc95371d90f9c878727d919b"
|
||||
integrity sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==
|
||||
|
||||
xml-name-validator@^5.0.0:
|
||||
version "5.0.0"
|
||||
|
||||
@@ -27,21 +27,25 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"tailscale.com/clientupdate/distsign"
|
||||
"tailscale.com/types/logger"
|
||||
"tailscale.com/util/cmpver"
|
||||
"tailscale.com/util/winutil"
|
||||
"tailscale.com/version"
|
||||
"tailscale.com/version/distro"
|
||||
)
|
||||
|
||||
const (
|
||||
CurrentTrack = ""
|
||||
StableTrack = "stable"
|
||||
UnstableTrack = "unstable"
|
||||
)
|
||||
|
||||
var CurrentTrack = func() string {
|
||||
if version.IsUnstableBuild() {
|
||||
return UnstableTrack
|
||||
} else {
|
||||
return StableTrack
|
||||
}
|
||||
}()
|
||||
|
||||
func versionToTrack(v string) (string, error) {
|
||||
_, rest, ok := strings.Cut(v, ".")
|
||||
if !ok {
|
||||
@@ -106,7 +110,7 @@ func (args Arguments) validate() error {
|
||||
return fmt.Errorf("only one of Version(%q) or Track(%q) can be set", args.Version, args.Track)
|
||||
}
|
||||
switch args.Track {
|
||||
case StableTrack, UnstableTrack, CurrentTrack:
|
||||
case StableTrack, UnstableTrack, "":
|
||||
// All valid values.
|
||||
default:
|
||||
return fmt.Errorf("unsupported track %q", args.Track)
|
||||
@@ -119,11 +123,17 @@ type Updater struct {
|
||||
// Update is a platform-specific method that updates the installation. May be
|
||||
// nil (not all platforms support updates from within Tailscale).
|
||||
Update func() error
|
||||
|
||||
// currentVersion is the short form of the current client version as
|
||||
// returned by version.Short(), typically "x.y.z". Used for tests to
|
||||
// override the actual current version.
|
||||
currentVersion string
|
||||
}
|
||||
|
||||
func NewUpdater(args Arguments) (*Updater, error) {
|
||||
up := Updater{
|
||||
Arguments: args,
|
||||
Arguments: args,
|
||||
currentVersion: version.Short(),
|
||||
}
|
||||
if up.Stdout == nil {
|
||||
up.Stdout = os.Stdout
|
||||
@@ -139,18 +149,15 @@ func NewUpdater(args Arguments) (*Updater, error) {
|
||||
if args.ForAutoUpdate && !canAutoUpdate {
|
||||
return nil, errors.ErrUnsupported
|
||||
}
|
||||
if up.Track == CurrentTrack {
|
||||
switch {
|
||||
case up.Version != "":
|
||||
if up.Track == "" {
|
||||
if up.Version != "" {
|
||||
var err error
|
||||
up.Track, err = versionToTrack(args.Version)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
case version.IsUnstableBuild():
|
||||
up.Track = UnstableTrack
|
||||
default:
|
||||
up.Track = StableTrack
|
||||
} else {
|
||||
up.Track = CurrentTrack
|
||||
}
|
||||
}
|
||||
if up.Arguments.PkgsAddr == "" {
|
||||
@@ -238,6 +245,11 @@ func (up *Updater) getUpdateFunction() (fn updateFunction, canAutoUpdate bool) {
|
||||
// CanAutoUpdate reports whether auto-updating via the clientupdate package
|
||||
// is supported for the current os/distro.
|
||||
func CanAutoUpdate() bool {
|
||||
if version.IsMacSysExt() {
|
||||
// Macsys uses Sparkle for auto-updates, which doesn't have an update
|
||||
// function in this package.
|
||||
return true
|
||||
}
|
||||
_, canAutoUpdate := (&Updater{}).getUpdateFunction()
|
||||
return canAutoUpdate
|
||||
}
|
||||
@@ -259,13 +271,16 @@ func Update(args Arguments) error {
|
||||
}
|
||||
|
||||
func (up *Updater) confirm(ver string) bool {
|
||||
switch cmpver.Compare(version.Short(), ver) {
|
||||
case 0:
|
||||
up.Logf("already running %v version %v; no update needed", up.Track, ver)
|
||||
return false
|
||||
case 1:
|
||||
up.Logf("installed %v version %v is newer than the latest available version %v; no update needed", up.Track, version.Short(), ver)
|
||||
return false
|
||||
// Only check version when we're not switching tracks.
|
||||
if up.Track == "" || up.Track == CurrentTrack {
|
||||
switch c := cmpver.Compare(up.currentVersion, ver); {
|
||||
case c == 0:
|
||||
up.Logf("already running %v version %v; no update needed", up.Track, ver)
|
||||
return false
|
||||
case c > 0:
|
||||
up.Logf("installed %v version %v is newer than the latest available version %v; no update needed", up.Track, up.currentVersion, ver)
|
||||
return false
|
||||
}
|
||||
}
|
||||
if up.Confirm != nil {
|
||||
return up.Confirm(ver)
|
||||
@@ -681,7 +696,7 @@ func parseAlpinePackageVersion(out []byte) (string, error) {
|
||||
return "", fmt.Errorf("malformed info line: %q", line)
|
||||
}
|
||||
ver := parts[1]
|
||||
if cmpver.Compare(ver, maxVer) == 1 {
|
||||
if cmpver.Compare(ver, maxVer) > 0 {
|
||||
maxVer = ver
|
||||
}
|
||||
}
|
||||
@@ -738,164 +753,6 @@ func (up *Updater) updateMacAppStore() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
const (
|
||||
// winMSIEnv is the environment variable that, if set, is the MSI file for
|
||||
// the update command to install. It's passed like this so we can stop the
|
||||
// tailscale.exe process from running before the msiexec process runs and
|
||||
// tries to overwrite ourselves.
|
||||
winMSIEnv = "TS_UPDATE_WIN_MSI"
|
||||
// winExePathEnv is the environment variable that is set along with
|
||||
// winMSIEnv and carries the full path of the calling tailscale.exe binary.
|
||||
// It is used to re-launch the GUI process (tailscale-ipn.exe) after
|
||||
// install is complete.
|
||||
winExePathEnv = "TS_UPDATE_WIN_EXE_PATH"
|
||||
)
|
||||
|
||||
var (
|
||||
verifyAuthenticode func(string) error // set non-nil only on Windows
|
||||
markTempFileFunc func(string) error // set non-nil only on Windows
|
||||
)
|
||||
|
||||
func (up *Updater) updateWindows() error {
|
||||
if msi := os.Getenv(winMSIEnv); msi != "" {
|
||||
// stdout/stderr from this part of the install could be lost since the
|
||||
// parent tailscaled is replaced. Create a temp log file to have some
|
||||
// output to debug with in case update fails.
|
||||
close, err := up.switchOutputToFile()
|
||||
if err != nil {
|
||||
up.Logf("failed to create log file for installation: %v; proceeding with existing outputs", err)
|
||||
} else {
|
||||
defer close.Close()
|
||||
}
|
||||
|
||||
up.Logf("installing %v ...", msi)
|
||||
if err := up.installMSI(msi); err != nil {
|
||||
up.Logf("MSI install failed: %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
up.Logf("success.")
|
||||
return nil
|
||||
}
|
||||
|
||||
if !winutil.IsCurrentProcessElevated() {
|
||||
return errors.New(`update must be run as Administrator
|
||||
|
||||
you can run the command prompt as Administrator one of these ways:
|
||||
* right-click cmd.exe, select 'Run as administrator'
|
||||
* press Windows+x, then press a
|
||||
* press Windows+r, type in "cmd", then press Ctrl+Shift+Enter`)
|
||||
}
|
||||
ver, err := requestedTailscaleVersion(up.Version, up.Track)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
arch := runtime.GOARCH
|
||||
if arch == "386" {
|
||||
arch = "x86"
|
||||
}
|
||||
if !up.confirm(ver) {
|
||||
return nil
|
||||
}
|
||||
|
||||
tsDir := filepath.Join(os.Getenv("ProgramData"), "Tailscale")
|
||||
msiDir := filepath.Join(tsDir, "MSICache")
|
||||
if fi, err := os.Stat(tsDir); err != nil {
|
||||
return fmt.Errorf("expected %s to exist, got stat error: %w", tsDir, err)
|
||||
} else if !fi.IsDir() {
|
||||
return fmt.Errorf("expected %s to be a directory; got %v", tsDir, fi.Mode())
|
||||
}
|
||||
if err := os.MkdirAll(msiDir, 0700); err != nil {
|
||||
return err
|
||||
}
|
||||
up.cleanupOldDownloads(filepath.Join(msiDir, "*.msi"))
|
||||
pkgsPath := fmt.Sprintf("%s/tailscale-setup-%s-%s.msi", up.Track, ver, arch)
|
||||
msiTarget := filepath.Join(msiDir, path.Base(pkgsPath))
|
||||
if err := up.downloadURLToFile(pkgsPath, msiTarget); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
up.Logf("verifying MSI authenticode...")
|
||||
if err := verifyAuthenticode(msiTarget); err != nil {
|
||||
return fmt.Errorf("authenticode verification of %s failed: %w", msiTarget, err)
|
||||
}
|
||||
up.Logf("authenticode verification succeeded")
|
||||
|
||||
up.Logf("making tailscale.exe copy to switch to...")
|
||||
up.cleanupOldDownloads(filepath.Join(os.TempDir(), "tailscale-updater-*.exe"))
|
||||
selfOrig, selfCopy, err := makeSelfCopy()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer os.Remove(selfCopy)
|
||||
up.Logf("running tailscale.exe copy for final install...")
|
||||
|
||||
cmd := exec.Command(selfCopy, "update")
|
||||
cmd.Env = append(os.Environ(), winMSIEnv+"="+msiTarget, winExePathEnv+"="+selfOrig)
|
||||
cmd.Stdout = up.Stderr
|
||||
cmd.Stderr = up.Stderr
|
||||
cmd.Stdin = os.Stdin
|
||||
if err := cmd.Start(); err != nil {
|
||||
return err
|
||||
}
|
||||
// Once it's started, exit ourselves, so the binary is free
|
||||
// to be replaced.
|
||||
os.Exit(0)
|
||||
panic("unreachable")
|
||||
}
|
||||
|
||||
func (up *Updater) switchOutputToFile() (io.Closer, error) {
|
||||
var logFilePath string
|
||||
exePath, err := os.Executable()
|
||||
if err != nil {
|
||||
logFilePath = filepath.Join(os.TempDir(), "tailscale-updater.log")
|
||||
} else {
|
||||
logFilePath = strings.TrimSuffix(exePath, ".exe") + ".log"
|
||||
}
|
||||
|
||||
up.Logf("writing update output to %q", logFilePath)
|
||||
logFile, err := os.Create(logFilePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
up.Logf = func(m string, args ...any) {
|
||||
fmt.Fprintf(logFile, m+"\n", args...)
|
||||
}
|
||||
up.Stdout = logFile
|
||||
up.Stderr = logFile
|
||||
return logFile, nil
|
||||
}
|
||||
|
||||
func (up *Updater) installMSI(msi string) error {
|
||||
var err error
|
||||
for tries := 0; tries < 2; tries++ {
|
||||
cmd := exec.Command("msiexec.exe", "/i", filepath.Base(msi), "/quiet", "/norestart", "/qn")
|
||||
cmd.Dir = filepath.Dir(msi)
|
||||
cmd.Stdout = up.Stdout
|
||||
cmd.Stderr = up.Stderr
|
||||
cmd.Stdin = os.Stdin
|
||||
err = cmd.Run()
|
||||
if err == nil {
|
||||
break
|
||||
}
|
||||
up.Logf("Install attempt failed: %v", err)
|
||||
uninstallVersion := version.Short()
|
||||
if v := os.Getenv("TS_DEBUG_UNINSTALL_VERSION"); v != "" {
|
||||
uninstallVersion = v
|
||||
}
|
||||
// Assume it's a downgrade, which msiexec won't permit. Uninstall our current version first.
|
||||
up.Logf("Uninstalling current version %q for downgrade...", uninstallVersion)
|
||||
cmd = exec.Command("msiexec.exe", "/x", msiUUIDForVersion(uninstallVersion), "/norestart", "/qn")
|
||||
cmd.Stdout = up.Stdout
|
||||
cmd.Stderr = up.Stderr
|
||||
cmd.Stdin = os.Stdin
|
||||
err = cmd.Run()
|
||||
up.Logf("msiexec uninstall: %v", err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// cleanupOldDownloads removes all files matching glob (see filepath.Glob).
|
||||
// Only regular files are removed, so the glob must match specific files and
|
||||
// not directories.
|
||||
@@ -920,53 +777,6 @@ func (up *Updater) cleanupOldDownloads(glob string) {
|
||||
}
|
||||
}
|
||||
|
||||
func msiUUIDForVersion(ver string) string {
|
||||
arch := runtime.GOARCH
|
||||
if arch == "386" {
|
||||
arch = "x86"
|
||||
}
|
||||
track, err := versionToTrack(ver)
|
||||
if err != nil {
|
||||
track = UnstableTrack
|
||||
}
|
||||
msiURL := fmt.Sprintf("https://pkgs.tailscale.com/%s/tailscale-setup-%s-%s.msi", track, ver, arch)
|
||||
return "{" + strings.ToUpper(uuid.NewSHA1(uuid.NameSpaceURL, []byte(msiURL)).String()) + "}"
|
||||
}
|
||||
|
||||
func makeSelfCopy() (origPathExe, tmpPathExe string, err error) {
|
||||
selfExe, err := os.Executable()
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
f, err := os.Open(selfExe)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
defer f.Close()
|
||||
f2, err := os.CreateTemp("", "tailscale-updater-*.exe")
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
if f := markTempFileFunc; f != nil {
|
||||
if err := f(f2.Name()); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
}
|
||||
if _, err := io.Copy(f2, f); err != nil {
|
||||
f2.Close()
|
||||
return "", "", err
|
||||
}
|
||||
return selfExe, f2.Name(), f2.Close()
|
||||
}
|
||||
|
||||
func (up *Updater) downloadURLToFile(pathSrc, fileDst string) (ret error) {
|
||||
c, err := distsign.NewClient(up.Logf, up.PkgsAddr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return c.Download(context.Background(), pathSrc, fileDst)
|
||||
}
|
||||
|
||||
func (up *Updater) updateFreeBSD() (err error) {
|
||||
if up.Version != "" {
|
||||
return errors.New("installing a specific version on FreeBSD is not supported")
|
||||
@@ -1331,12 +1141,8 @@ func requestedTailscaleVersion(ver, track string) (string, error) {
|
||||
// LatestTailscaleVersion returns the latest released version for the given
|
||||
// track from pkgs.tailscale.com.
|
||||
func LatestTailscaleVersion(track string) (string, error) {
|
||||
if track == CurrentTrack {
|
||||
if version.IsUnstableBuild() {
|
||||
track = UnstableTrack
|
||||
} else {
|
||||
track = StableTrack
|
||||
}
|
||||
if track == "" {
|
||||
track = CurrentTrack
|
||||
}
|
||||
|
||||
latest, err := latestPackages(track)
|
||||
|
||||
20
clientupdate/clientupdate_downloads.go
Normal file
20
clientupdate/clientupdate_downloads.go
Normal file
@@ -0,0 +1,20 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
//go:build (linux && !android) || windows
|
||||
|
||||
package clientupdate
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"tailscale.com/clientupdate/distsign"
|
||||
)
|
||||
|
||||
func (up *Updater) downloadURLToFile(pathSrc, fileDst string) (ret error) {
|
||||
c, err := distsign.NewClient(up.Logf, up.PkgsAddr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return c.Download(context.Background(), pathSrc, fileDst)
|
||||
}
|
||||
10
clientupdate/clientupdate_not_downloads.go
Normal file
10
clientupdate/clientupdate_not_downloads.go
Normal file
@@ -0,0 +1,10 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
//go:build !((linux && !android) || windows)
|
||||
|
||||
package clientupdate
|
||||
|
||||
func (up *Updater) downloadURLToFile(pathSrc, fileDst string) (ret error) {
|
||||
panic("unreachable")
|
||||
}
|
||||
10
clientupdate/clientupdate_notwindows.go
Normal file
10
clientupdate/clientupdate_notwindows.go
Normal file
@@ -0,0 +1,10 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
//go:build !windows
|
||||
|
||||
package clientupdate
|
||||
|
||||
func (up *Updater) updateWindows() error {
|
||||
panic("unreachable")
|
||||
}
|
||||
@@ -846,3 +846,107 @@ func TestParseUnraidPluginVersion(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfirm(t *testing.T) {
|
||||
curTrack := CurrentTrack
|
||||
defer func() { CurrentTrack = curTrack }()
|
||||
|
||||
tests := []struct {
|
||||
desc string
|
||||
fromTrack string
|
||||
toTrack string
|
||||
fromVer string
|
||||
toVer string
|
||||
confirm func(string) bool
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
desc: "on latest stable",
|
||||
fromTrack: StableTrack,
|
||||
toTrack: StableTrack,
|
||||
fromVer: "1.66.0",
|
||||
toVer: "1.66.0",
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
desc: "stable upgrade",
|
||||
fromTrack: StableTrack,
|
||||
toTrack: StableTrack,
|
||||
fromVer: "1.66.0",
|
||||
toVer: "1.68.0",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
desc: "unstable upgrade",
|
||||
fromTrack: UnstableTrack,
|
||||
toTrack: UnstableTrack,
|
||||
fromVer: "1.67.1",
|
||||
toVer: "1.67.2",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
desc: "from stable to unstable",
|
||||
fromTrack: StableTrack,
|
||||
toTrack: UnstableTrack,
|
||||
fromVer: "1.66.0",
|
||||
toVer: "1.67.1",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
desc: "from unstable to stable",
|
||||
fromTrack: UnstableTrack,
|
||||
toTrack: StableTrack,
|
||||
fromVer: "1.67.1",
|
||||
toVer: "1.66.0",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
desc: "confirm callback rejects",
|
||||
fromTrack: StableTrack,
|
||||
toTrack: StableTrack,
|
||||
fromVer: "1.66.0",
|
||||
toVer: "1.66.1",
|
||||
confirm: func(string) bool {
|
||||
return false
|
||||
},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
desc: "confirm callback allows",
|
||||
fromTrack: StableTrack,
|
||||
toTrack: StableTrack,
|
||||
fromVer: "1.66.0",
|
||||
toVer: "1.66.1",
|
||||
confirm: func(string) bool {
|
||||
return true
|
||||
},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
desc: "downgrade",
|
||||
fromTrack: StableTrack,
|
||||
toTrack: StableTrack,
|
||||
fromVer: "1.66.1",
|
||||
toVer: "1.66.0",
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.desc, func(t *testing.T) {
|
||||
CurrentTrack = tt.fromTrack
|
||||
up := Updater{
|
||||
currentVersion: tt.fromVer,
|
||||
Arguments: Arguments{
|
||||
Track: tt.toTrack,
|
||||
Confirm: tt.confirm,
|
||||
Logf: t.Logf,
|
||||
},
|
||||
}
|
||||
|
||||
if got := up.confirm(tt.toVer); got != tt.want {
|
||||
t.Errorf("got %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,13 +7,57 @@
|
||||
package clientupdate
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/sys/windows"
|
||||
"tailscale.com/util/winutil"
|
||||
"tailscale.com/util/winutil/authenticode"
|
||||
)
|
||||
|
||||
func init() {
|
||||
markTempFileFunc = markTempFileWindows
|
||||
verifyAuthenticode = verifyTailscale
|
||||
const (
|
||||
// winMSIEnv is the environment variable that, if set, is the MSI file for
|
||||
// the update command to install. It's passed like this so we can stop the
|
||||
// tailscale.exe process from running before the msiexec process runs and
|
||||
// tries to overwrite ourselves.
|
||||
winMSIEnv = "TS_UPDATE_WIN_MSI"
|
||||
// winExePathEnv is the environment variable that is set along with
|
||||
// winMSIEnv and carries the full path of the calling tailscale.exe binary.
|
||||
// It is used to re-launch the GUI process (tailscale-ipn.exe) after
|
||||
// install is complete.
|
||||
winExePathEnv = "TS_UPDATE_WIN_EXE_PATH"
|
||||
)
|
||||
|
||||
func makeSelfCopy() (origPathExe, tmpPathExe string, err error) {
|
||||
selfExe, err := os.Executable()
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
f, err := os.Open(selfExe)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
defer f.Close()
|
||||
f2, err := os.CreateTemp("", "tailscale-updater-*.exe")
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
if err := markTempFileWindows(f2.Name()); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
if _, err := io.Copy(f2, f); err != nil {
|
||||
f2.Close()
|
||||
return "", "", err
|
||||
}
|
||||
return selfExe, f2.Name(), f2.Close()
|
||||
}
|
||||
|
||||
func markTempFileWindows(name string) error {
|
||||
@@ -23,6 +67,159 @@ func markTempFileWindows(name string) error {
|
||||
|
||||
const certSubjectTailscale = "Tailscale Inc."
|
||||
|
||||
func verifyTailscale(path string) error {
|
||||
func verifyAuthenticode(path string) error {
|
||||
return authenticode.Verify(path, certSubjectTailscale)
|
||||
}
|
||||
|
||||
func (up *Updater) updateWindows() error {
|
||||
if msi := os.Getenv(winMSIEnv); msi != "" {
|
||||
// stdout/stderr from this part of the install could be lost since the
|
||||
// parent tailscaled is replaced. Create a temp log file to have some
|
||||
// output to debug with in case update fails.
|
||||
close, err := up.switchOutputToFile()
|
||||
if err != nil {
|
||||
up.Logf("failed to create log file for installation: %v; proceeding with existing outputs", err)
|
||||
} else {
|
||||
defer close.Close()
|
||||
}
|
||||
|
||||
up.Logf("installing %v ...", msi)
|
||||
if err := up.installMSI(msi); err != nil {
|
||||
up.Logf("MSI install failed: %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
up.Logf("success.")
|
||||
return nil
|
||||
}
|
||||
|
||||
if !winutil.IsCurrentProcessElevated() {
|
||||
return errors.New(`update must be run as Administrator
|
||||
|
||||
you can run the command prompt as Administrator one of these ways:
|
||||
* right-click cmd.exe, select 'Run as administrator'
|
||||
* press Windows+x, then press a
|
||||
* press Windows+r, type in "cmd", then press Ctrl+Shift+Enter`)
|
||||
}
|
||||
ver, err := requestedTailscaleVersion(up.Version, up.Track)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
arch := runtime.GOARCH
|
||||
if arch == "386" {
|
||||
arch = "x86"
|
||||
}
|
||||
if !up.confirm(ver) {
|
||||
return nil
|
||||
}
|
||||
|
||||
tsDir := filepath.Join(os.Getenv("ProgramData"), "Tailscale")
|
||||
msiDir := filepath.Join(tsDir, "MSICache")
|
||||
if fi, err := os.Stat(tsDir); err != nil {
|
||||
return fmt.Errorf("expected %s to exist, got stat error: %w", tsDir, err)
|
||||
} else if !fi.IsDir() {
|
||||
return fmt.Errorf("expected %s to be a directory; got %v", tsDir, fi.Mode())
|
||||
}
|
||||
if err := os.MkdirAll(msiDir, 0700); err != nil {
|
||||
return err
|
||||
}
|
||||
up.cleanupOldDownloads(filepath.Join(msiDir, "*.msi"))
|
||||
pkgsPath := fmt.Sprintf("%s/tailscale-setup-%s-%s.msi", up.Track, ver, arch)
|
||||
msiTarget := filepath.Join(msiDir, path.Base(pkgsPath))
|
||||
if err := up.downloadURLToFile(pkgsPath, msiTarget); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
up.Logf("verifying MSI authenticode...")
|
||||
if err := verifyAuthenticode(msiTarget); err != nil {
|
||||
return fmt.Errorf("authenticode verification of %s failed: %w", msiTarget, err)
|
||||
}
|
||||
up.Logf("authenticode verification succeeded")
|
||||
|
||||
up.Logf("making tailscale.exe copy to switch to...")
|
||||
up.cleanupOldDownloads(filepath.Join(os.TempDir(), "tailscale-updater-*.exe"))
|
||||
selfOrig, selfCopy, err := makeSelfCopy()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer os.Remove(selfCopy)
|
||||
up.Logf("running tailscale.exe copy for final install...")
|
||||
|
||||
cmd := exec.Command(selfCopy, "update")
|
||||
cmd.Env = append(os.Environ(), winMSIEnv+"="+msiTarget, winExePathEnv+"="+selfOrig)
|
||||
cmd.Stdout = up.Stderr
|
||||
cmd.Stderr = up.Stderr
|
||||
cmd.Stdin = os.Stdin
|
||||
if err := cmd.Start(); err != nil {
|
||||
return err
|
||||
}
|
||||
// Once it's started, exit ourselves, so the binary is free
|
||||
// to be replaced.
|
||||
os.Exit(0)
|
||||
panic("unreachable")
|
||||
}
|
||||
|
||||
func (up *Updater) installMSI(msi string) error {
|
||||
var err error
|
||||
for tries := 0; tries < 2; tries++ {
|
||||
cmd := exec.Command("msiexec.exe", "/i", filepath.Base(msi), "/quiet", "/norestart", "/qn")
|
||||
cmd.Dir = filepath.Dir(msi)
|
||||
cmd.Stdout = up.Stdout
|
||||
cmd.Stderr = up.Stderr
|
||||
cmd.Stdin = os.Stdin
|
||||
err = cmd.Run()
|
||||
if err == nil {
|
||||
break
|
||||
}
|
||||
up.Logf("Install attempt failed: %v", err)
|
||||
uninstallVersion := up.currentVersion
|
||||
if v := os.Getenv("TS_DEBUG_UNINSTALL_VERSION"); v != "" {
|
||||
uninstallVersion = v
|
||||
}
|
||||
// Assume it's a downgrade, which msiexec won't permit. Uninstall our current version first.
|
||||
up.Logf("Uninstalling current version %q for downgrade...", uninstallVersion)
|
||||
cmd = exec.Command("msiexec.exe", "/x", msiUUIDForVersion(uninstallVersion), "/norestart", "/qn")
|
||||
cmd.Stdout = up.Stdout
|
||||
cmd.Stderr = up.Stderr
|
||||
cmd.Stdin = os.Stdin
|
||||
err = cmd.Run()
|
||||
up.Logf("msiexec uninstall: %v", err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func msiUUIDForVersion(ver string) string {
|
||||
arch := runtime.GOARCH
|
||||
if arch == "386" {
|
||||
arch = "x86"
|
||||
}
|
||||
track, err := versionToTrack(ver)
|
||||
if err != nil {
|
||||
track = UnstableTrack
|
||||
}
|
||||
msiURL := fmt.Sprintf("https://pkgs.tailscale.com/%s/tailscale-setup-%s-%s.msi", track, ver, arch)
|
||||
return "{" + strings.ToUpper(uuid.NewSHA1(uuid.NameSpaceURL, []byte(msiURL)).String()) + "}"
|
||||
}
|
||||
|
||||
func (up *Updater) switchOutputToFile() (io.Closer, error) {
|
||||
var logFilePath string
|
||||
exePath, err := os.Executable()
|
||||
if err != nil {
|
||||
logFilePath = filepath.Join(os.TempDir(), "tailscale-updater.log")
|
||||
} else {
|
||||
logFilePath = strings.TrimSuffix(exePath, ".exe") + ".log"
|
||||
}
|
||||
|
||||
up.Logf("writing update output to %q", logFilePath)
|
||||
logFile, err := os.Create(logFilePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
up.Logf = func(m string, args ...any) {
|
||||
fmt.Fprintf(logFile, m+"\n", args...)
|
||||
}
|
||||
up.Stdout = logFile
|
||||
up.Stderr = logFile
|
||||
return logFile, nil
|
||||
}
|
||||
|
||||
@@ -47,7 +47,7 @@ func main() {
|
||||
it := codegen.NewImportTracker(pkg.Types)
|
||||
buf := new(bytes.Buffer)
|
||||
for _, typeName := range typeNames {
|
||||
typ, ok := namedTypes[typeName]
|
||||
typ, ok := namedTypes[typeName].(*types.Named)
|
||||
if !ok {
|
||||
log.Fatalf("could not find type %s", typeName)
|
||||
}
|
||||
@@ -78,7 +78,11 @@ func main() {
|
||||
w(" return false")
|
||||
w("}")
|
||||
}
|
||||
cloneOutput := pkg.Name + "_clone.go"
|
||||
cloneOutput := pkg.Name + "_clone"
|
||||
if *flagBuildTags == "test" {
|
||||
cloneOutput += "_test"
|
||||
}
|
||||
cloneOutput += ".go"
|
||||
if err := codegen.WritePackageFile("tailscale.com/cmd/cloner", pkg, cloneOutput, it, buf); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
@@ -91,16 +95,19 @@ func gen(buf *bytes.Buffer, it *codegen.ImportTracker, typ *types.Named) {
|
||||
}
|
||||
|
||||
name := typ.Obj().Name()
|
||||
typeParams := typ.Origin().TypeParams()
|
||||
_, typeParamNames := codegen.FormatTypeParams(typeParams, it)
|
||||
nameWithParams := name + typeParamNames
|
||||
fmt.Fprintf(buf, "// Clone makes a deep copy of %s.\n", name)
|
||||
fmt.Fprintf(buf, "// The result aliases no memory with the original.\n")
|
||||
fmt.Fprintf(buf, "func (src *%s) Clone() *%s {\n", name, name)
|
||||
fmt.Fprintf(buf, "func (src *%s) Clone() *%s {\n", nameWithParams, nameWithParams)
|
||||
writef := func(format string, args ...any) {
|
||||
fmt.Fprintf(buf, "\t"+format+"\n", args...)
|
||||
}
|
||||
writef("if src == nil {")
|
||||
writef("\treturn nil")
|
||||
writef("}")
|
||||
writef("dst := new(%s)", name)
|
||||
writef("dst := new(%s)", nameWithParams)
|
||||
writef("*dst = *src")
|
||||
for i := range t.NumFields() {
|
||||
fname := t.Field(i).Name()
|
||||
@@ -108,7 +115,7 @@ func gen(buf *bytes.Buffer, it *codegen.ImportTracker, typ *types.Named) {
|
||||
if !codegen.ContainsPointers(ft) || codegen.HasNoClone(t.Tag(i)) {
|
||||
continue
|
||||
}
|
||||
if named, _ := ft.(*types.Named); named != nil {
|
||||
if named, _ := codegen.NamedTypeOf(ft); named != nil {
|
||||
if codegen.IsViewType(ft) {
|
||||
writef("dst.%s = src.%s", fname, fname)
|
||||
continue
|
||||
@@ -126,16 +133,23 @@ func gen(buf *bytes.Buffer, it *codegen.ImportTracker, typ *types.Named) {
|
||||
writef("dst.%s = make([]%s, len(src.%s))", fname, n, fname)
|
||||
writef("for i := range dst.%s {", fname)
|
||||
if ptr, isPtr := ft.Elem().(*types.Pointer); isPtr {
|
||||
if _, isBasic := ptr.Elem().Underlying().(*types.Basic); isBasic {
|
||||
it.Import("tailscale.com/types/ptr")
|
||||
writef("if src.%s[i] == nil { dst.%s[i] = nil } else {", fname, fname)
|
||||
writef("\tdst.%s[i] = ptr.To(*src.%s[i])", fname, fname)
|
||||
writef("}")
|
||||
writef("if src.%s[i] == nil { dst.%s[i] = nil } else {", fname, fname)
|
||||
if codegen.ContainsPointers(ptr.Elem()) {
|
||||
if _, isIface := ptr.Elem().Underlying().(*types.Interface); isIface {
|
||||
it.Import("tailscale.com/types/ptr")
|
||||
writef("\tdst.%s[i] = ptr.To((*src.%s[i]).Clone())", fname, fname)
|
||||
} else {
|
||||
writef("\tdst.%s[i] = src.%s[i].Clone()", fname, fname)
|
||||
}
|
||||
} else {
|
||||
writef("\tdst.%s[i] = src.%s[i].Clone()", fname, fname)
|
||||
it.Import("tailscale.com/types/ptr")
|
||||
writef("\tdst.%s[i] = ptr.To(*src.%s[i])", fname, fname)
|
||||
}
|
||||
writef("}")
|
||||
} else if ft.Elem().String() == "encoding/json.RawMessage" {
|
||||
writef("\tdst.%s[i] = append(src.%s[i][:0:0], src.%s[i]...)", fname, fname, fname)
|
||||
} else if _, isIface := ft.Elem().Underlying().(*types.Interface); isIface {
|
||||
writef("\tdst.%s[i] = src.%s[i].Clone()", fname, fname)
|
||||
} else {
|
||||
writef("\tdst.%s[i] = *src.%s[i].Clone()", fname, fname)
|
||||
}
|
||||
@@ -145,14 +159,19 @@ func gen(buf *bytes.Buffer, it *codegen.ImportTracker, typ *types.Named) {
|
||||
writef("dst.%s = append(src.%s[:0:0], src.%s...)", fname, fname, fname)
|
||||
}
|
||||
case *types.Pointer:
|
||||
if named, _ := ft.Elem().(*types.Named); named != nil && codegen.ContainsPointers(ft.Elem()) {
|
||||
base := ft.Elem()
|
||||
hasPtrs := codegen.ContainsPointers(base)
|
||||
if named, _ := codegen.NamedTypeOf(base); named != nil && hasPtrs {
|
||||
writef("dst.%s = src.%s.Clone()", fname, fname)
|
||||
continue
|
||||
}
|
||||
it.Import("tailscale.com/types/ptr")
|
||||
writef("if dst.%s != nil {", fname)
|
||||
writef("\tdst.%s = ptr.To(*src.%s)", fname, fname)
|
||||
if codegen.ContainsPointers(ft.Elem()) {
|
||||
if _, isIface := base.Underlying().(*types.Interface); isIface && hasPtrs {
|
||||
writef("\tdst.%s = ptr.To((*src.%s).Clone())", fname, fname)
|
||||
} else if !hasPtrs {
|
||||
writef("\tdst.%s = ptr.To(*src.%s)", fname, fname)
|
||||
} else {
|
||||
writef("\t" + `panic("TODO pointers in pointers")`)
|
||||
}
|
||||
writef("}")
|
||||
@@ -172,18 +191,50 @@ func gen(buf *bytes.Buffer, it *codegen.ImportTracker, typ *types.Named) {
|
||||
writef("if dst.%s != nil {", fname)
|
||||
writef("\tdst.%s = map[%s]%s{}", fname, it.QualifiedName(ft.Key()), it.QualifiedName(elem))
|
||||
writef("\tfor k, v := range src.%s {", fname)
|
||||
switch elem.(type) {
|
||||
|
||||
switch elem := elem.Underlying().(type) {
|
||||
case *types.Pointer:
|
||||
writef("\t\tdst.%s[k] = v.Clone()", fname)
|
||||
writef("\t\tif v == nil { dst.%s[k] = nil } else {", fname)
|
||||
if base := elem.Elem().Underlying(); codegen.ContainsPointers(base) {
|
||||
if _, isIface := base.(*types.Interface); isIface {
|
||||
it.Import("tailscale.com/types/ptr")
|
||||
writef("\t\t\tdst.%s[k] = ptr.To((*v).Clone())", fname)
|
||||
} else {
|
||||
writef("\t\t\tdst.%s[k] = v.Clone()", fname)
|
||||
}
|
||||
} else {
|
||||
it.Import("tailscale.com/types/ptr")
|
||||
writef("\t\t\tdst.%s[k] = ptr.To(*v)", fname)
|
||||
}
|
||||
writef("}")
|
||||
case *types.Interface:
|
||||
if cloneResultType := methodResultType(elem, "Clone"); cloneResultType != nil {
|
||||
if _, isPtr := cloneResultType.(*types.Pointer); isPtr {
|
||||
writef("\t\tdst.%s[k] = *(v.Clone())", fname)
|
||||
} else {
|
||||
writef("\t\tdst.%s[k] = v.Clone()", fname)
|
||||
}
|
||||
} else {
|
||||
writef(`panic("%s (%v) does not have a Clone method")`, fname, elem)
|
||||
}
|
||||
default:
|
||||
writef("\t\tdst.%s[k] = *(v.Clone())", fname)
|
||||
}
|
||||
|
||||
writef("\t}")
|
||||
writef("}")
|
||||
} else {
|
||||
it.Import("maps")
|
||||
writef("\tdst.%s = maps.Clone(src.%s)", fname, fname)
|
||||
}
|
||||
case *types.Interface:
|
||||
// If ft is an interface with a "Clone() ft" method, it can be used to clone the field.
|
||||
// This includes scenarios where ft is a constrained type parameter.
|
||||
if cloneResultType := methodResultType(ft, "Clone"); cloneResultType.Underlying() == ft {
|
||||
writef("dst.%s = src.%s.Clone()", fname, fname)
|
||||
continue
|
||||
}
|
||||
writef(`panic("%s (%v) does not have a compatible Clone method")`, fname, ft)
|
||||
default:
|
||||
writef(`panic("TODO: %s (%T)")`, fname, ft)
|
||||
}
|
||||
@@ -191,7 +242,7 @@ func gen(buf *bytes.Buffer, it *codegen.ImportTracker, typ *types.Named) {
|
||||
writef("return dst")
|
||||
fmt.Fprintf(buf, "}\n\n")
|
||||
|
||||
buf.Write(codegen.AssertStructUnchanged(t, name, "Clone", it))
|
||||
buf.Write(codegen.AssertStructUnchanged(t, name, typeParams, "Clone", it))
|
||||
}
|
||||
|
||||
// hasBasicUnderlying reports true when typ.Underlying() is a slice or a map.
|
||||
@@ -203,3 +254,15 @@ func hasBasicUnderlying(typ types.Type) bool {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func methodResultType(typ types.Type, method string) types.Type {
|
||||
viewMethod := codegen.LookupMethod(typ, method)
|
||||
if viewMethod == nil {
|
||||
return nil
|
||||
}
|
||||
sig, ok := viewMethod.Type().(*types.Signature)
|
||||
if !ok || sig.Results().Len() != 1 {
|
||||
return nil
|
||||
}
|
||||
return sig.Results().At(0).Type()
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
|
||||
//go:generate go run tailscale.com/cmd/cloner -clonefunc=true -type SliceContainer
|
||||
|
||||
// Package clonerex is an example package for the cloner tool.
|
||||
package clonerex
|
||||
|
||||
type SliceContainer struct {
|
||||
|
||||
262
cmd/containerboot/forwarding.go
Normal file
262
cmd/containerboot/forwarding.go
Normal file
@@ -0,0 +1,262 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
//go:build linux
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"net/netip"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"tailscale.com/util/linuxfw"
|
||||
)
|
||||
|
||||
// ensureIPForwarding enables IPv4/IPv6 forwarding for the container.
|
||||
func ensureIPForwarding(root, clusterProxyTargetIP, tailnetTargetIP, tailnetTargetFQDN string, routes *string) error {
|
||||
var (
|
||||
v4Forwarding, v6Forwarding bool
|
||||
)
|
||||
if clusterProxyTargetIP != "" {
|
||||
proxyIP, err := netip.ParseAddr(clusterProxyTargetIP)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid cluster destination IP: %v", err)
|
||||
}
|
||||
if proxyIP.Is4() {
|
||||
v4Forwarding = true
|
||||
} else {
|
||||
v6Forwarding = true
|
||||
}
|
||||
}
|
||||
if tailnetTargetIP != "" {
|
||||
proxyIP, err := netip.ParseAddr(tailnetTargetIP)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid tailnet destination IP: %v", err)
|
||||
}
|
||||
if proxyIP.Is4() {
|
||||
v4Forwarding = true
|
||||
} else {
|
||||
v6Forwarding = true
|
||||
}
|
||||
}
|
||||
// Currently we only proxy traffic to the IPv4 address of the tailnet
|
||||
// target.
|
||||
if tailnetTargetFQDN != "" {
|
||||
v4Forwarding = true
|
||||
}
|
||||
if routes != nil && *routes != "" {
|
||||
for _, route := range strings.Split(*routes, ",") {
|
||||
cidr, err := netip.ParsePrefix(route)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid subnet route: %v", err)
|
||||
}
|
||||
if cidr.Addr().Is4() {
|
||||
v4Forwarding = true
|
||||
} else {
|
||||
v6Forwarding = true
|
||||
}
|
||||
}
|
||||
}
|
||||
return enableIPForwarding(v4Forwarding, v6Forwarding, root)
|
||||
}
|
||||
|
||||
func enableIPForwarding(v4Forwarding, v6Forwarding bool, root string) error {
|
||||
var paths []string
|
||||
if v4Forwarding {
|
||||
paths = append(paths, filepath.Join(root, "proc/sys/net/ipv4/ip_forward"))
|
||||
}
|
||||
if v6Forwarding {
|
||||
paths = append(paths, filepath.Join(root, "proc/sys/net/ipv6/conf/all/forwarding"))
|
||||
}
|
||||
|
||||
// In some common configurations (e.g. default docker,
|
||||
// kubernetes), the container environment denies write access to
|
||||
// most sysctls, including IP forwarding controls. Check the
|
||||
// sysctl values before trying to change them, so that we
|
||||
// gracefully do nothing if the container's already been set up
|
||||
// properly by e.g. a k8s initContainer.
|
||||
for _, path := range paths {
|
||||
bs, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("reading %q: %w", path, err)
|
||||
}
|
||||
if v := strings.TrimSpace(string(bs)); v != "1" {
|
||||
if err := os.WriteFile(path, []byte("1"), 0644); err != nil {
|
||||
return fmt.Errorf("enabling %q: %w", path, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func installEgressForwardingRule(_ context.Context, dstStr string, tsIPs []netip.Prefix, nfr linuxfw.NetfilterRunner) error {
|
||||
dst, err := netip.ParseAddr(dstStr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var local netip.Addr
|
||||
for _, pfx := range tsIPs {
|
||||
if !pfx.IsSingleIP() {
|
||||
continue
|
||||
}
|
||||
if pfx.Addr().Is4() != dst.Is4() {
|
||||
continue
|
||||
}
|
||||
local = pfx.Addr()
|
||||
break
|
||||
}
|
||||
if !local.IsValid() {
|
||||
return fmt.Errorf("no tailscale IP matching family of %s found in %v", dstStr, tsIPs)
|
||||
}
|
||||
if err := nfr.DNATNonTailscaleTraffic("tailscale0", dst); err != nil {
|
||||
return fmt.Errorf("installing egress proxy rules: %w", err)
|
||||
}
|
||||
if err := nfr.EnsureSNATForDst(local, dst); err != nil {
|
||||
return fmt.Errorf("installing egress proxy rules: %w", err)
|
||||
}
|
||||
if err := nfr.ClampMSSToPMTU("tailscale0", dst); err != nil {
|
||||
return fmt.Errorf("installing egress proxy rules: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// installTSForwardingRuleForDestination accepts a destination address and a
|
||||
// list of node's tailnet addresses, sets up rules to forward traffic for
|
||||
// destination to the tailnet IP matching the destination IP family.
|
||||
// Destination can be Pod IP of this node.
|
||||
func installTSForwardingRuleForDestination(_ context.Context, dstFilter string, tsIPs []netip.Prefix, nfr linuxfw.NetfilterRunner) error {
|
||||
dst, err := netip.ParseAddr(dstFilter)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var local netip.Addr
|
||||
for _, pfx := range tsIPs {
|
||||
if !pfx.IsSingleIP() {
|
||||
continue
|
||||
}
|
||||
if pfx.Addr().Is4() != dst.Is4() {
|
||||
continue
|
||||
}
|
||||
local = pfx.Addr()
|
||||
break
|
||||
}
|
||||
if !local.IsValid() {
|
||||
return fmt.Errorf("no tailscale IP matching family of %s found in %v", dstFilter, tsIPs)
|
||||
}
|
||||
if err := nfr.AddDNATRule(dst, local); err != nil {
|
||||
return fmt.Errorf("installing rule for forwarding traffic to tailnet IP: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func installIngressForwardingRule(_ context.Context, dstStr string, tsIPs []netip.Prefix, nfr linuxfw.NetfilterRunner) error {
|
||||
dst, err := netip.ParseAddr(dstStr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var local netip.Addr
|
||||
proxyHasIPv4Address := false
|
||||
for _, pfx := range tsIPs {
|
||||
if !pfx.IsSingleIP() {
|
||||
continue
|
||||
}
|
||||
if pfx.Addr().Is4() {
|
||||
proxyHasIPv4Address = true
|
||||
}
|
||||
if pfx.Addr().Is4() != dst.Is4() {
|
||||
continue
|
||||
}
|
||||
local = pfx.Addr()
|
||||
break
|
||||
}
|
||||
if proxyHasIPv4Address && dst.Is6() {
|
||||
log.Printf("Warning: proxy backend ClusterIP is an IPv6 address and the proxy has a IPv4 tailnet address. You might need to disable IPv4 address allocation for the proxy for forwarding to work. See https://github.com/tailscale/tailscale/issues/12156")
|
||||
}
|
||||
if !local.IsValid() {
|
||||
return fmt.Errorf("no tailscale IP matching family of %s found in %v", dstStr, tsIPs)
|
||||
}
|
||||
if err := nfr.AddDNATRule(local, dst); err != nil {
|
||||
return fmt.Errorf("installing ingress proxy rules: %w", err)
|
||||
}
|
||||
if err := nfr.ClampMSSToPMTU("tailscale0", dst); err != nil {
|
||||
return fmt.Errorf("installing ingress proxy rules: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func installIngressForwardingRuleForDNSTarget(_ context.Context, backendAddrs []net.IP, tsIPs []netip.Prefix, nfr linuxfw.NetfilterRunner) error {
|
||||
var (
|
||||
tsv4 netip.Addr
|
||||
tsv6 netip.Addr
|
||||
v4Backends []netip.Addr
|
||||
v6Backends []netip.Addr
|
||||
)
|
||||
for _, pfx := range tsIPs {
|
||||
if pfx.IsSingleIP() && pfx.Addr().Is4() {
|
||||
tsv4 = pfx.Addr()
|
||||
continue
|
||||
}
|
||||
if pfx.IsSingleIP() && pfx.Addr().Is6() {
|
||||
tsv6 = pfx.Addr()
|
||||
continue
|
||||
}
|
||||
}
|
||||
// TODO: log if more than one backend address is found and firewall is
|
||||
// in nftables mode that only the first IP will be used.
|
||||
for _, ip := range backendAddrs {
|
||||
if ip.To4() != nil {
|
||||
v4Backends = append(v4Backends, netip.AddrFrom4([4]byte(ip.To4())))
|
||||
}
|
||||
if ip.To16() != nil {
|
||||
v6Backends = append(v6Backends, netip.AddrFrom16([16]byte(ip.To16())))
|
||||
}
|
||||
}
|
||||
|
||||
// Enable IP forwarding here as opposed to at the start of containerboot
|
||||
// as the IPv4/IPv6 requirements might have changed.
|
||||
// For Kubernetes operator proxies, forwarding for both IPv4 and IPv6 is
|
||||
// enabled by an init container, so in practice enabling forwarding here
|
||||
// is only needed if this proxy has been configured by manually setting
|
||||
// TS_EXPERIMENTAL_DEST_DNS_NAME env var for a containerboot instance.
|
||||
if err := enableIPForwarding(len(v4Backends) != 0, len(v6Backends) != 0, ""); err != nil {
|
||||
log.Printf("[unexpected] failed to ensure IP forwarding: %v", err)
|
||||
}
|
||||
|
||||
updateFirewall := func(dst netip.Addr, backendTargets []netip.Addr) error {
|
||||
if err := nfr.DNATWithLoadBalancer(dst, backendTargets); err != nil {
|
||||
return fmt.Errorf("installing DNAT rules for ingress backends %+#v: %w", backendTargets, err)
|
||||
}
|
||||
// The backend might advertize MSS higher than that of the
|
||||
// tailscale interfaces. Clamp MSS of packets going out via
|
||||
// tailscale0 interface to its MTU to prevent broken connections
|
||||
// in environments where path MTU discovery is not working.
|
||||
if err := nfr.ClampMSSToPMTU("tailscale0", dst); err != nil {
|
||||
return fmt.Errorf("adding rule to clamp traffic via tailscale0: %v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
if len(v4Backends) != 0 {
|
||||
if !tsv4.IsValid() {
|
||||
log.Printf("backend targets %v contain at least one IPv4 address, but this node's Tailscale IPs do not contain a valid IPv4 address: %v", backendAddrs, tsIPs)
|
||||
} else if err := updateFirewall(tsv4, v4Backends); err != nil {
|
||||
return fmt.Errorf("Installing IPv4 firewall rules: %w", err)
|
||||
}
|
||||
}
|
||||
if len(v6Backends) != 0 && !tsv6.IsValid() {
|
||||
if !tsv6.IsValid() {
|
||||
log.Printf("backend targets %v contain at least one IPv6 address, but this node's Tailscale IPs do not contain a valid IPv6 address: %v", backendAddrs, tsIPs)
|
||||
} else if !nfr.HasIPV6NAT() {
|
||||
log.Printf("backend targets %v contain at least one IPv6 address, but the chosen firewall mode does not support IPv6 NAT", backendAddrs)
|
||||
} else if err := updateFirewall(tsv6, v6Backends); err != nil {
|
||||
return fmt.Errorf("Installing IPv6 firewall rules: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
51
cmd/containerboot/healthz.go
Normal file
51
cmd/containerboot/healthz.go
Normal file
@@ -0,0 +1,51 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
//go:build linux
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// healthz is a simple health check server, if enabled it returns 200 OK if
|
||||
// this tailscale node currently has at least one tailnet IP address else
|
||||
// returns 503.
|
||||
type healthz struct {
|
||||
sync.Mutex
|
||||
hasAddrs bool
|
||||
}
|
||||
|
||||
func (h *healthz) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
h.Lock()
|
||||
defer h.Unlock()
|
||||
if h.hasAddrs {
|
||||
w.Write([]byte("ok"))
|
||||
} else {
|
||||
http.Error(w, "node currently has no tailscale IPs", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// runHealthz runs a simple HTTP health endpoint on /healthz, listening on the
|
||||
// provided address. A containerized tailscale instance is considered healthy if
|
||||
// it has at least one tailnet IP address.
|
||||
func runHealthz(addr string, h *healthz) {
|
||||
lis, err := net.Listen("tcp", addr)
|
||||
if err != nil {
|
||||
log.Fatalf("error listening on the provided health endpoint address %q: %v", addr, err)
|
||||
}
|
||||
mux := http.NewServeMux()
|
||||
mux.Handle("/healthz", h)
|
||||
log.Printf("Running healthcheck endpoint at %s/healthz", addr)
|
||||
hs := &http.Server{Handler: mux}
|
||||
|
||||
go func() {
|
||||
if err := hs.Serve(lis); err != nil {
|
||||
log.Fatalf("failed running health endpoint: %v", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
@@ -8,33 +8,31 @@ package main
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"os"
|
||||
|
||||
"tailscale.com/kube"
|
||||
"tailscale.com/kube/kubeapi"
|
||||
"tailscale.com/kube/kubeclient"
|
||||
"tailscale.com/tailcfg"
|
||||
)
|
||||
|
||||
// storeDeviceInfo writes deviceID into the "device_id" data field of the kube
|
||||
// secret secretName.
|
||||
func storeDeviceInfo(ctx context.Context, secretName string, deviceID tailcfg.StableNodeID, fqdn string, addresses []netip.Prefix) error {
|
||||
// First check if the secret exists at all. Even if running on
|
||||
// kubernetes, we do not necessarily store state in a k8s secret.
|
||||
if _, err := kc.GetSecret(ctx, secretName); err != nil {
|
||||
if s, ok := err.(*kube.Status); ok {
|
||||
if s.Code >= 400 && s.Code <= 499 {
|
||||
// Assume the secret doesn't exist, or we don't have
|
||||
// permission to access it.
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return err
|
||||
// storeDeviceID writes deviceID to 'device_id' data field of the named
|
||||
// Kubernetes Secret.
|
||||
func storeDeviceID(ctx context.Context, secretName string, deviceID tailcfg.StableNodeID) error {
|
||||
s := &kubeapi.Secret{
|
||||
Data: map[string][]byte{
|
||||
"device_id": []byte(deviceID),
|
||||
},
|
||||
}
|
||||
return kc.StrategicMergePatchSecret(ctx, secretName, s, "tailscale-container")
|
||||
}
|
||||
|
||||
// storeDeviceEndpoints writes device's tailnet IPs and MagicDNS name to fields
|
||||
// 'device_ips', 'device_fqdn' of the named Kubernetes Secret.
|
||||
func storeDeviceEndpoints(ctx context.Context, secretName string, fqdn string, addresses []netip.Prefix) error {
|
||||
var ips []string
|
||||
for _, addr := range addresses {
|
||||
ips = append(ips, addr.Addr().String())
|
||||
@@ -44,28 +42,27 @@ func storeDeviceInfo(ctx context.Context, secretName string, deviceID tailcfg.St
|
||||
return err
|
||||
}
|
||||
|
||||
m := &kube.Secret{
|
||||
s := &kubeapi.Secret{
|
||||
Data: map[string][]byte{
|
||||
"device_id": []byte(deviceID),
|
||||
"device_fqdn": []byte(fqdn),
|
||||
"device_ips": deviceIPs,
|
||||
},
|
||||
}
|
||||
return kc.StrategicMergePatchSecret(ctx, secretName, m, "tailscale-container")
|
||||
return kc.StrategicMergePatchSecret(ctx, secretName, s, "tailscale-container")
|
||||
}
|
||||
|
||||
// deleteAuthKey deletes the 'authkey' field of the given kube
|
||||
// secret. No-op if there is no authkey in the secret.
|
||||
func deleteAuthKey(ctx context.Context, secretName string) error {
|
||||
// m is a JSON Patch data structure, see https://jsonpatch.com/ or RFC 6902.
|
||||
m := []kube.JSONPatch{
|
||||
m := []kubeclient.JSONPatch{
|
||||
{
|
||||
Op: "remove",
|
||||
Path: "/data/authkey",
|
||||
},
|
||||
}
|
||||
if err := kc.JSONPatchSecret(ctx, secretName, m); err != nil {
|
||||
if s, ok := err.(*kube.Status); ok && s.Code == http.StatusUnprocessableEntity {
|
||||
if err := kc.JSONPatchResource(ctx, secretName, kubeclient.TypeSecrets, m); err != nil {
|
||||
if s, ok := err.(*kubeapi.Status); ok && s.Code == http.StatusUnprocessableEntity {
|
||||
// This is kubernetes-ese for "the field you asked to
|
||||
// delete already doesn't exist", aka no-op.
|
||||
return nil
|
||||
@@ -75,66 +72,16 @@ func deleteAuthKey(ctx context.Context, secretName string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
var kc kube.Client
|
||||
|
||||
// setupKube is responsible for doing any necessary configuration and checks to
|
||||
// ensure that tailscale state storage and authentication mechanism will work on
|
||||
// Kubernetes.
|
||||
func (cfg *settings) setupKube(ctx context.Context) error {
|
||||
if cfg.KubeSecret == "" {
|
||||
return nil
|
||||
}
|
||||
canPatch, canCreate, err := kc.CheckSecretPermissions(ctx, cfg.KubeSecret)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Some Kubernetes permissions are missing, please check your RBAC configuration: %v", err)
|
||||
}
|
||||
cfg.KubernetesCanPatch = canPatch
|
||||
|
||||
s, err := kc.GetSecret(ctx, cfg.KubeSecret)
|
||||
if err != nil && kube.IsNotFoundErr(err) && !canCreate {
|
||||
return fmt.Errorf("Tailscale state Secret %s does not exist and we don't have permissions to create it. "+
|
||||
"If you intend to store tailscale state elsewhere than a Kubernetes Secret, "+
|
||||
"you can explicitly set TS_KUBE_SECRET env var to an empty string. "+
|
||||
"Else ensure that RBAC is set up that allows the service account associated with this installation to create Secrets.", cfg.KubeSecret)
|
||||
} else if err != nil && !kube.IsNotFoundErr(err) {
|
||||
return fmt.Errorf("Getting Tailscale state Secret %s: %v", cfg.KubeSecret, err)
|
||||
}
|
||||
|
||||
if cfg.AuthKey == "" && !isOneStepConfig(cfg) {
|
||||
if s == nil {
|
||||
log.Print("TS_AUTHKEY not provided and kube secret does not exist, login will be interactive if needed.")
|
||||
return nil
|
||||
}
|
||||
keyBytes, _ := s.Data["authkey"]
|
||||
key := string(keyBytes)
|
||||
|
||||
if key != "" {
|
||||
// This behavior of pulling authkeys from kube secrets was added
|
||||
// at the same time as the patch permission, so we can enforce
|
||||
// that we must be able to patch out the authkey after
|
||||
// authenticating if you want to use this feature. This avoids
|
||||
// us having to deal with the case where we might leave behind
|
||||
// an unnecessary reusable authkey in a secret, like a rake in
|
||||
// the grass.
|
||||
if !cfg.KubernetesCanPatch {
|
||||
return errors.New("authkey found in TS_KUBE_SECRET, but the pod doesn't have patch permissions on the secret to manage the authkey.")
|
||||
}
|
||||
cfg.AuthKey = key
|
||||
} else {
|
||||
log.Print("No authkey found in kube secret and TS_AUTHKEY not provided, login will be interactive if needed.")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
var kc kubeclient.Client
|
||||
|
||||
func initKubeClient(root string) {
|
||||
if root != "/" {
|
||||
// If we are running in a test, we need to set the root path to the fake
|
||||
// service account directory.
|
||||
kube.SetRootPathForTesting(root)
|
||||
kubeclient.SetRootPathForTesting(root)
|
||||
}
|
||||
var err error
|
||||
kc, err = kube.New()
|
||||
kc, err = kubeclient.New("tailscale-container")
|
||||
if err != nil {
|
||||
log.Fatalf("Error creating kube client: %v", err)
|
||||
}
|
||||
|
||||
@@ -11,7 +11,8 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/google/go-cmp/cmp"
|
||||
"tailscale.com/kube"
|
||||
"tailscale.com/kube/kubeapi"
|
||||
"tailscale.com/kube/kubeclient"
|
||||
)
|
||||
|
||||
func TestSetupKube(t *testing.T) {
|
||||
@@ -20,7 +21,7 @@ func TestSetupKube(t *testing.T) {
|
||||
cfg *settings
|
||||
wantErr bool
|
||||
wantCfg *settings
|
||||
kc kube.Client
|
||||
kc kubeclient.Client
|
||||
}{
|
||||
{
|
||||
name: "TS_AUTHKEY set, state Secret exists",
|
||||
@@ -28,11 +29,11 @@ func TestSetupKube(t *testing.T) {
|
||||
AuthKey: "foo",
|
||||
KubeSecret: "foo",
|
||||
},
|
||||
kc: &kube.FakeClient{
|
||||
kc: &kubeclient.FakeClient{
|
||||
CheckSecretPermissionsImpl: func(context.Context, string) (bool, bool, error) {
|
||||
return false, false, nil
|
||||
},
|
||||
GetSecretImpl: func(context.Context, string) (*kube.Secret, error) {
|
||||
GetSecretImpl: func(context.Context, string) (*kubeapi.Secret, error) {
|
||||
return nil, nil
|
||||
},
|
||||
},
|
||||
@@ -47,12 +48,12 @@ func TestSetupKube(t *testing.T) {
|
||||
AuthKey: "foo",
|
||||
KubeSecret: "foo",
|
||||
},
|
||||
kc: &kube.FakeClient{
|
||||
kc: &kubeclient.FakeClient{
|
||||
CheckSecretPermissionsImpl: func(context.Context, string) (bool, bool, error) {
|
||||
return false, true, nil
|
||||
},
|
||||
GetSecretImpl: func(context.Context, string) (*kube.Secret, error) {
|
||||
return nil, &kube.Status{Code: 404}
|
||||
GetSecretImpl: func(context.Context, string) (*kubeapi.Secret, error) {
|
||||
return nil, &kubeapi.Status{Code: 404}
|
||||
},
|
||||
},
|
||||
wantCfg: &settings{
|
||||
@@ -66,12 +67,12 @@ func TestSetupKube(t *testing.T) {
|
||||
AuthKey: "foo",
|
||||
KubeSecret: "foo",
|
||||
},
|
||||
kc: &kube.FakeClient{
|
||||
kc: &kubeclient.FakeClient{
|
||||
CheckSecretPermissionsImpl: func(context.Context, string) (bool, bool, error) {
|
||||
return false, false, nil
|
||||
},
|
||||
GetSecretImpl: func(context.Context, string) (*kube.Secret, error) {
|
||||
return nil, &kube.Status{Code: 404}
|
||||
GetSecretImpl: func(context.Context, string) (*kubeapi.Secret, error) {
|
||||
return nil, &kubeapi.Status{Code: 404}
|
||||
},
|
||||
},
|
||||
wantCfg: &settings{
|
||||
@@ -86,12 +87,12 @@ func TestSetupKube(t *testing.T) {
|
||||
AuthKey: "foo",
|
||||
KubeSecret: "foo",
|
||||
},
|
||||
kc: &kube.FakeClient{
|
||||
kc: &kubeclient.FakeClient{
|
||||
CheckSecretPermissionsImpl: func(context.Context, string) (bool, bool, error) {
|
||||
return false, false, nil
|
||||
},
|
||||
GetSecretImpl: func(context.Context, string) (*kube.Secret, error) {
|
||||
return nil, &kube.Status{Code: 403}
|
||||
GetSecretImpl: func(context.Context, string) (*kubeapi.Secret, error) {
|
||||
return nil, &kubeapi.Status{Code: 403}
|
||||
},
|
||||
},
|
||||
wantCfg: &settings{
|
||||
@@ -110,7 +111,7 @@ func TestSetupKube(t *testing.T) {
|
||||
AuthKey: "foo",
|
||||
KubeSecret: "foo",
|
||||
},
|
||||
kc: &kube.FakeClient{
|
||||
kc: &kubeclient.FakeClient{
|
||||
CheckSecretPermissionsImpl: func(context.Context, string) (bool, bool, error) {
|
||||
return false, false, errors.New("broken")
|
||||
},
|
||||
@@ -126,12 +127,12 @@ func TestSetupKube(t *testing.T) {
|
||||
wantCfg: &settings{
|
||||
KubeSecret: "foo",
|
||||
},
|
||||
kc: &kube.FakeClient{
|
||||
kc: &kubeclient.FakeClient{
|
||||
CheckSecretPermissionsImpl: func(context.Context, string) (bool, bool, error) {
|
||||
return false, true, nil
|
||||
},
|
||||
GetSecretImpl: func(context.Context, string) (*kube.Secret, error) {
|
||||
return nil, &kube.Status{Code: 404}
|
||||
GetSecretImpl: func(context.Context, string) (*kubeapi.Secret, error) {
|
||||
return nil, &kubeapi.Status{Code: 404}
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -144,12 +145,12 @@ func TestSetupKube(t *testing.T) {
|
||||
wantCfg: &settings{
|
||||
KubeSecret: "foo",
|
||||
},
|
||||
kc: &kube.FakeClient{
|
||||
kc: &kubeclient.FakeClient{
|
||||
CheckSecretPermissionsImpl: func(context.Context, string) (bool, bool, error) {
|
||||
return false, false, nil
|
||||
},
|
||||
GetSecretImpl: func(context.Context, string) (*kube.Secret, error) {
|
||||
return &kube.Secret{}, nil
|
||||
GetSecretImpl: func(context.Context, string) (*kubeapi.Secret, error) {
|
||||
return &kubeapi.Secret{}, nil
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -158,12 +159,12 @@ func TestSetupKube(t *testing.T) {
|
||||
cfg: &settings{
|
||||
KubeSecret: "foo",
|
||||
},
|
||||
kc: &kube.FakeClient{
|
||||
kc: &kubeclient.FakeClient{
|
||||
CheckSecretPermissionsImpl: func(context.Context, string) (bool, bool, error) {
|
||||
return false, false, nil
|
||||
},
|
||||
GetSecretImpl: func(context.Context, string) (*kube.Secret, error) {
|
||||
return &kube.Secret{Data: map[string][]byte{"authkey": []byte("foo")}}, nil
|
||||
GetSecretImpl: func(context.Context, string) (*kubeapi.Secret, error) {
|
||||
return &kubeapi.Secret{Data: map[string][]byte{"authkey": []byte("foo")}}, nil
|
||||
},
|
||||
},
|
||||
wantCfg: &settings{
|
||||
@@ -176,12 +177,12 @@ func TestSetupKube(t *testing.T) {
|
||||
cfg: &settings{
|
||||
KubeSecret: "foo",
|
||||
},
|
||||
kc: &kube.FakeClient{
|
||||
kc: &kubeclient.FakeClient{
|
||||
CheckSecretPermissionsImpl: func(context.Context, string) (bool, bool, error) {
|
||||
return true, false, nil
|
||||
},
|
||||
GetSecretImpl: func(context.Context, string) (*kube.Secret, error) {
|
||||
return &kube.Secret{Data: map[string][]byte{"authkey": []byte("foo")}}, nil
|
||||
GetSecretImpl: func(context.Context, string) (*kubeapi.Secret, error) {
|
||||
return &kubeapi.Secret{Data: map[string][]byte{"authkey": []byte("foo")}}, nil
|
||||
},
|
||||
},
|
||||
wantCfg: &settings{
|
||||
|
||||
@@ -52,6 +52,12 @@
|
||||
// ${TS_CERT_DOMAIN}, it will be replaced with the value of the available FQDN.
|
||||
// It cannot be used in conjunction with TS_DEST_IP. The file is watched for changes,
|
||||
// and will be re-applied when it changes.
|
||||
// - TS_HEALTHCHECK_ADDR_PORT: if specified, an HTTP health endpoint will be
|
||||
// served at /healthz at the provided address, which should be in form [<address>]:<port>.
|
||||
// If not set, no health check will be run. If set to :<port>, addr will default to 0.0.0.0
|
||||
// The health endpoint will return 200 OK if this node has at least one tailnet IP address,
|
||||
// otherwise returns 503.
|
||||
// NB: the health criteria might change in the future.
|
||||
// - TS_EXPERIMENTAL_VERSIONED_CONFIG_DIR: if specified, a path to a
|
||||
// directory that containers tailscaled config in file. The config file needs to be
|
||||
// named cap-<current-tailscaled-cap>.hujson. If this is set, TS_HOSTNAME,
|
||||
@@ -61,6 +67,11 @@
|
||||
// and not `tailscale up` or `tailscale set`.
|
||||
// The config file contents are currently read once on container start.
|
||||
// NB: This env var is currently experimental and the logic will likely change!
|
||||
// TS_EXPERIMENTAL_ENABLE_FORWARDING_OPTIMIZATIONS: set to true to
|
||||
// autoconfigure the default network interface for optimal performance for
|
||||
// Tailscale subnet router/exit node.
|
||||
// https://tailscale.com/kb/1320/performance-best-practices#linux-optimizations-for-subnet-routers-and-exit-nodes
|
||||
// NB: This env var is currently experimental and the logic will likely change!
|
||||
// - EXPERIMENTAL_ALLOW_PROXYING_CLUSTER_TRAFFIC_VIA_INGRESS: if set to true
|
||||
// and if this containerboot instance is an L7 ingress proxy (created by
|
||||
// the Kubernetes operator), set up rules to allow proxying cluster traffic,
|
||||
@@ -81,9 +92,7 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
@@ -92,24 +101,18 @@ import (
|
||||
"net"
|
||||
"net/netip"
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/signal"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/fsnotify/fsnotify"
|
||||
"golang.org/x/sys/unix"
|
||||
"tailscale.com/client/tailscale"
|
||||
"tailscale.com/ipn"
|
||||
"tailscale.com/ipn/conffile"
|
||||
kubeutils "tailscale.com/k8s-operator"
|
||||
"tailscale.com/tailcfg"
|
||||
"tailscale.com/types/logger"
|
||||
@@ -128,33 +131,9 @@ func newNetfilterRunner(logf logger.Logf) (linuxfw.NetfilterRunner, error) {
|
||||
func main() {
|
||||
log.SetPrefix("boot: ")
|
||||
tailscale.I_Acknowledge_This_API_Is_Unstable = true
|
||||
cfg := &settings{
|
||||
AuthKey: defaultEnvs([]string{"TS_AUTHKEY", "TS_AUTH_KEY"}, ""),
|
||||
Hostname: defaultEnv("TS_HOSTNAME", ""),
|
||||
Routes: defaultEnvStringPointer("TS_ROUTES"),
|
||||
ServeConfigPath: defaultEnv("TS_SERVE_CONFIG", ""),
|
||||
ProxyTargetIP: defaultEnv("TS_DEST_IP", ""),
|
||||
ProxyTargetDNSName: defaultEnv("TS_EXPERIMENTAL_DEST_DNS_NAME", ""),
|
||||
TailnetTargetIP: defaultEnv("TS_TAILNET_TARGET_IP", ""),
|
||||
TailnetTargetFQDN: defaultEnv("TS_TAILNET_TARGET_FQDN", ""),
|
||||
DaemonExtraArgs: defaultEnv("TS_TAILSCALED_EXTRA_ARGS", ""),
|
||||
ExtraArgs: defaultEnv("TS_EXTRA_ARGS", ""),
|
||||
InKubernetes: os.Getenv("KUBERNETES_SERVICE_HOST") != "",
|
||||
UserspaceMode: defaultBool("TS_USERSPACE", true),
|
||||
StateDir: defaultEnv("TS_STATE_DIR", ""),
|
||||
AcceptDNS: defaultEnvBoolPointer("TS_ACCEPT_DNS"),
|
||||
KubeSecret: defaultEnv("TS_KUBE_SECRET", "tailscale"),
|
||||
SOCKSProxyAddr: defaultEnv("TS_SOCKS5_SERVER", ""),
|
||||
HTTPProxyAddr: defaultEnv("TS_OUTBOUND_HTTP_PROXY_LISTEN", ""),
|
||||
Socket: defaultEnv("TS_SOCKET", "/tmp/tailscaled.sock"),
|
||||
AuthOnce: defaultBool("TS_AUTH_ONCE", false),
|
||||
Root: defaultEnv("TS_TEST_ONLY_ROOT", "/"),
|
||||
TailscaledConfigFilePath: tailscaledConfigFilePath(),
|
||||
AllowProxyingClusterTrafficViaIngress: defaultBool("EXPERIMENTAL_ALLOW_PROXYING_CLUSTER_TRAFFIC_VIA_INGRESS", false),
|
||||
PodIP: defaultEnv("POD_IP", ""),
|
||||
}
|
||||
|
||||
if err := cfg.validate(); err != nil {
|
||||
cfg, err := configFromEnv()
|
||||
if err != nil {
|
||||
log.Fatalf("invalid configuration: %v", err)
|
||||
}
|
||||
|
||||
@@ -199,6 +178,12 @@ func main() {
|
||||
}
|
||||
defer killTailscaled()
|
||||
|
||||
if cfg.EnableForwardingOptimizations {
|
||||
if err := client.SetUDPGROForwarding(bootCtx); err != nil {
|
||||
log.Printf("[unexpected] error enabling UDP GRO forwarding: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
w, err := client.WatchIPNBus(bootCtx, ipn.NotifyInitialNetMap|ipn.NotifyInitialPrefs|ipn.NotifyInitialState)
|
||||
if err != nil {
|
||||
log.Fatalf("failed to watch tailscaled for updates: %v", err)
|
||||
@@ -263,10 +248,8 @@ authLoop:
|
||||
switch *n.State {
|
||||
case ipn.NeedsLogin:
|
||||
if isOneStepConfig(cfg) {
|
||||
// This could happen if this is the
|
||||
// first time tailscaled was run for
|
||||
// this device and the auth key was not
|
||||
// passed via the configfile.
|
||||
// This could happen if this is the first time tailscaled was run for this
|
||||
// device and the auth key was not passed via the configfile.
|
||||
log.Fatalf("invalid state: tailscaled daemon started with a config file, but tailscale is not logged in: ensure you pass a valid auth key in the config file.")
|
||||
}
|
||||
if err := authTailscale(); err != nil {
|
||||
@@ -309,7 +292,7 @@ authLoop:
|
||||
}
|
||||
}
|
||||
|
||||
if cfg.InKubernetes && cfg.KubeSecret != "" && cfg.KubernetesCanPatch && isTwoStepConfigAuthOnce(cfg) {
|
||||
if hasKubeStateStore(cfg) && isTwoStepConfigAuthOnce(cfg) {
|
||||
// We were told to only auth once, so any secret-bound
|
||||
// authkey is no longer needed. We don't strictly need to
|
||||
// wipe it, but it's good hygiene.
|
||||
@@ -325,11 +308,10 @@ authLoop:
|
||||
}
|
||||
|
||||
var (
|
||||
wantProxy = cfg.ProxyTargetIP != "" || cfg.ProxyTargetDNSName != "" || cfg.TailnetTargetIP != "" || cfg.TailnetTargetFQDN != "" || cfg.AllowProxyingClusterTrafficViaIngress
|
||||
wantDeviceInfo = cfg.InKubernetes && cfg.KubeSecret != "" && cfg.KubernetesCanPatch
|
||||
startupTasksDone = false
|
||||
currentIPs deephash.Sum // tailscale IPs assigned to device
|
||||
currentDeviceInfo deephash.Sum // device ID and fqdn
|
||||
startupTasksDone = false
|
||||
currentIPs deephash.Sum // tailscale IPs assigned to device
|
||||
currentDeviceID deephash.Sum // device ID
|
||||
currentDeviceEndpoints deephash.Sum // device FQDN and IPs
|
||||
|
||||
currentEgressIPs deephash.Sum
|
||||
|
||||
@@ -338,12 +320,15 @@ authLoop:
|
||||
|
||||
certDomain = new(atomic.Pointer[string])
|
||||
certDomainChanged = make(chan bool, 1)
|
||||
|
||||
h = &healthz{} // http server for the healthz endpoint
|
||||
healthzRunner = sync.OnceFunc(func() { runHealthz(cfg.HealthCheckAddrPort, h) })
|
||||
)
|
||||
if cfg.ServeConfigPath != "" {
|
||||
go watchServeConfigChanges(ctx, cfg.ServeConfigPath, certDomainChanged, certDomain, client)
|
||||
}
|
||||
var nfr linuxfw.NetfilterRunner
|
||||
if wantProxy {
|
||||
if isL3Proxy(cfg) {
|
||||
nfr, err = newNetfilterRunner(log.Printf)
|
||||
if err != nil {
|
||||
log.Fatalf("error creating new netfilter runner: %v", err)
|
||||
@@ -362,6 +347,9 @@ authLoop:
|
||||
}
|
||||
})
|
||||
)
|
||||
// egressSvcsErrorChan will get an error sent to it if this containerboot instance is configured to expose 1+
|
||||
// egress services in HA mode and errored.
|
||||
var egressSvcsErrorChan = make(chan error)
|
||||
defer t.Stop()
|
||||
// resetTimer resets timer for when to next attempt to resolve the DNS
|
||||
// name for the proxy configured with TS_EXPERIMENTAL_DEST_DNS_NAME. The
|
||||
@@ -387,6 +375,7 @@ authLoop:
|
||||
failedResolveAttempts++
|
||||
}
|
||||
|
||||
var egressSvcsNotify chan ipn.Notify
|
||||
notifyChan := make(chan ipn.Notify)
|
||||
errChan := make(chan error)
|
||||
go func() {
|
||||
@@ -428,6 +417,20 @@ runLoop:
|
||||
newCurrentIPs := deephash.Hash(&addrs)
|
||||
ipsHaveChanged := newCurrentIPs != currentIPs
|
||||
|
||||
// Store device ID in a Kubernetes Secret before
|
||||
// setting up any routing rules. This ensures
|
||||
// that, for containerboot instances that are
|
||||
// Kubernetes operator proxies, the operator is
|
||||
// able to retrieve the device ID from the
|
||||
// Kubernetes Secret to clean up tailnet nodes
|
||||
// for proxies whose route setup continuously
|
||||
// fails.
|
||||
deviceID := n.NetMap.SelfNode.StableID()
|
||||
if hasKubeStateStore(cfg) && deephash.Update(¤tDeviceID, &deviceID) {
|
||||
if err := storeDeviceID(ctx, cfg.KubeSecret, n.NetMap.SelfNode.StableID()); err != nil {
|
||||
log.Fatalf("storing device ID in Kubernetes Secret: %v", err)
|
||||
}
|
||||
}
|
||||
if cfg.TailnetTargetFQDN != "" {
|
||||
var (
|
||||
egressAddrs []netip.Prefix
|
||||
@@ -450,19 +453,25 @@ runLoop:
|
||||
egressAddrs = node.Addresses().AsSlice()
|
||||
newCurentEgressIPs = deephash.Hash(&egressAddrs)
|
||||
egressIPsHaveChanged = newCurentEgressIPs != currentEgressIPs
|
||||
if egressIPsHaveChanged && len(egressAddrs) != 0 {
|
||||
// The firewall rules get (re-)installed:
|
||||
// - on startup
|
||||
// - when the tailnet IPs of the tailnet target have changed
|
||||
// - when the tailnet IPs of this node have changed
|
||||
if (egressIPsHaveChanged || ipsHaveChanged) && len(egressAddrs) != 0 {
|
||||
var rulesInstalled bool
|
||||
for _, egressAddr := range egressAddrs {
|
||||
ea := egressAddr.Addr()
|
||||
// TODO (irbekrm): make it work for IPv6 too.
|
||||
if ea.Is6() {
|
||||
log.Println("Not installing egress forwarding rules for IPv6 as this is currently not supported")
|
||||
continue
|
||||
}
|
||||
log.Printf("Installing forwarding rules for destination %v", ea.String())
|
||||
if err := installEgressForwardingRule(ctx, ea.String(), addrs, nfr); err != nil {
|
||||
log.Fatalf("installing egress proxy rules for destination %s: %v", ea.String(), err)
|
||||
if ea.Is4() || (ea.Is6() && nfr.HasIPV6NAT()) {
|
||||
rulesInstalled = true
|
||||
log.Printf("Installing forwarding rules for destination %v", ea.String())
|
||||
if err := installEgressForwardingRule(ctx, ea.String(), addrs, nfr); err != nil {
|
||||
log.Fatalf("installing egress proxy rules for destination %s: %v", ea.String(), err)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !rulesInstalled {
|
||||
log.Fatalf("no forwarding rules for egress addresses %v, host supports IPv6: %v", egressAddrs, nfr.HasIPV6NAT())
|
||||
}
|
||||
}
|
||||
currentEgressIPs = newCurentEgressIPs
|
||||
}
|
||||
@@ -521,27 +530,74 @@ runLoop:
|
||||
}
|
||||
currentIPs = newCurrentIPs
|
||||
|
||||
deviceInfo := []any{n.NetMap.SelfNode.StableID(), n.NetMap.SelfNode.Name()}
|
||||
if cfg.InKubernetes && cfg.KubernetesCanPatch && cfg.KubeSecret != "" && deephash.Update(¤tDeviceInfo, &deviceInfo) {
|
||||
if err := storeDeviceInfo(ctx, cfg.KubeSecret, n.NetMap.SelfNode.StableID(), n.NetMap.SelfNode.Name(), n.NetMap.SelfNode.Addresses().AsSlice()); err != nil {
|
||||
log.Fatalf("storing device ID in kube secret: %v", err)
|
||||
// Only store device FQDN and IP addresses to
|
||||
// Kubernetes Secret when any required proxy
|
||||
// route setup has succeeded. IPs and FQDN are
|
||||
// read from the Secret by the Tailscale
|
||||
// Kubernetes operator and, for some proxy
|
||||
// types, such as Tailscale Ingress, advertized
|
||||
// on the Ingress status. Writing them to the
|
||||
// Secret only after the proxy routing has been
|
||||
// set up ensures that the operator does not
|
||||
// advertize endpoints of broken proxies.
|
||||
// TODO (irbekrm): instead of using the IP and FQDN, have some other mechanism for the proxy signal that it is 'Ready'.
|
||||
deviceEndpoints := []any{n.NetMap.SelfNode.Name(), n.NetMap.SelfNode.Addresses()}
|
||||
if hasKubeStateStore(cfg) && deephash.Update(¤tDeviceEndpoints, &deviceEndpoints) {
|
||||
if err := storeDeviceEndpoints(ctx, cfg.KubeSecret, n.NetMap.SelfNode.Name(), n.NetMap.SelfNode.Addresses().AsSlice()); err != nil {
|
||||
log.Fatalf("storing device IPs and FQDN in Kubernetes Secret: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
if cfg.HealthCheckAddrPort != "" {
|
||||
h.Lock()
|
||||
h.hasAddrs = len(addrs) != 0
|
||||
h.Unlock()
|
||||
healthzRunner()
|
||||
}
|
||||
if egressSvcsNotify != nil {
|
||||
egressSvcsNotify <- n
|
||||
}
|
||||
}
|
||||
if !startupTasksDone {
|
||||
if (!wantProxy || currentIPs != deephash.Sum{}) && (!wantDeviceInfo || currentDeviceInfo != deephash.Sum{}) {
|
||||
// For containerboot instances that act as TCP proxies (proxying traffic to an endpoint
|
||||
// passed via one of the env vars that containerboot reads) and store state in a
|
||||
// Kubernetes Secret, we consider startup tasks done at the point when device info has
|
||||
// been successfully stored to state Secret. For all other containerboot instances, if
|
||||
// we just get to this point the startup tasks can be considered done.
|
||||
if !isL3Proxy(cfg) || !hasKubeStateStore(cfg) || (currentDeviceEndpoints != deephash.Sum{} && currentDeviceID != deephash.Sum{}) {
|
||||
// This log message is used in tests to detect when all
|
||||
// post-auth configuration is done.
|
||||
log.Println("Startup complete, waiting for shutdown signal")
|
||||
startupTasksDone = true
|
||||
|
||||
// Wait on tailscaled process. It won't
|
||||
// be cleaned up by default when the
|
||||
// container exits as it is not PID1.
|
||||
// TODO (irbekrm): perhaps we can
|
||||
// replace the reaper by a running
|
||||
// cmd.Wait in a goroutine immediately
|
||||
// after starting tailscaled?
|
||||
// Configure egress proxy. Egress proxy will set up firewall rules to proxy
|
||||
// traffic to tailnet targets configured in the provided configuration file. It
|
||||
// will then continuously monitor the config file and netmap updates and
|
||||
// reconfigure the firewall rules as needed. If any of its operations fail, it
|
||||
// will crash this node.
|
||||
if cfg.EgressSvcsCfgPath != "" {
|
||||
log.Printf("configuring egress proxy using configuration file at %s", cfg.EgressSvcsCfgPath)
|
||||
egressSvcsNotify = make(chan ipn.Notify)
|
||||
ep := egressProxy{
|
||||
cfgPath: cfg.EgressSvcsCfgPath,
|
||||
nfr: nfr,
|
||||
kc: kc,
|
||||
stateSecret: cfg.KubeSecret,
|
||||
netmapChan: egressSvcsNotify,
|
||||
podIPv4: cfg.PodIPv4,
|
||||
tailnetAddrs: addrs,
|
||||
}
|
||||
go func() {
|
||||
if err := ep.run(ctx, n); err != nil {
|
||||
egressSvcsErrorChan <- err
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// Wait on tailscaled process. It won't be cleaned up by default when the
|
||||
// container exits as it is not PID1. TODO (irbekrm): perhaps we can replace the
|
||||
// reaper by a running cmd.Wait in a goroutine immediately after starting
|
||||
// tailscaled?
|
||||
reaper := func() {
|
||||
defer wg.Done()
|
||||
for {
|
||||
@@ -579,226 +635,13 @@ runLoop:
|
||||
}
|
||||
backendAddrs = newBackendAddrs
|
||||
resetTimer(false)
|
||||
case e := <-egressSvcsErrorChan:
|
||||
log.Fatalf("egress proxy failed: %v", e)
|
||||
}
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
// watchServeConfigChanges watches path for changes, and when it sees one, reads
|
||||
// the serve config from it, replacing ${TS_CERT_DOMAIN} with certDomain, and
|
||||
// applies it to lc. It exits when ctx is canceled. cdChanged is a channel that
|
||||
// is written to when the certDomain changes, causing the serve config to be
|
||||
// re-read and applied.
|
||||
func watchServeConfigChanges(ctx context.Context, path string, cdChanged <-chan bool, certDomainAtomic *atomic.Pointer[string], lc *tailscale.LocalClient) {
|
||||
if certDomainAtomic == nil {
|
||||
panic("cd must not be nil")
|
||||
}
|
||||
var tickChan <-chan time.Time
|
||||
var eventChan <-chan fsnotify.Event
|
||||
if w, err := fsnotify.NewWatcher(); err != nil {
|
||||
log.Printf("failed to create fsnotify watcher, timer-only mode: %v", err)
|
||||
ticker := time.NewTicker(5 * time.Second)
|
||||
defer ticker.Stop()
|
||||
tickChan = ticker.C
|
||||
} else {
|
||||
defer w.Close()
|
||||
if err := w.Add(filepath.Dir(path)); err != nil {
|
||||
log.Fatalf("failed to add fsnotify watch: %v", err)
|
||||
}
|
||||
eventChan = w.Events
|
||||
}
|
||||
|
||||
var certDomain string
|
||||
var prevServeConfig *ipn.ServeConfig
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-cdChanged:
|
||||
certDomain = *certDomainAtomic.Load()
|
||||
case <-tickChan:
|
||||
case <-eventChan:
|
||||
// We can't do any reasonable filtering on the event because of how
|
||||
// k8s handles these mounts. So just re-read the file and apply it
|
||||
// if it's changed.
|
||||
}
|
||||
if certDomain == "" {
|
||||
continue
|
||||
}
|
||||
sc, err := readServeConfig(path, certDomain)
|
||||
if err != nil {
|
||||
log.Fatalf("failed to read serve config: %v", err)
|
||||
}
|
||||
if prevServeConfig != nil && reflect.DeepEqual(sc, prevServeConfig) {
|
||||
continue
|
||||
}
|
||||
log.Printf("Applying serve config")
|
||||
if err := lc.SetServeConfig(ctx, sc); err != nil {
|
||||
log.Fatalf("failed to set serve config: %v", err)
|
||||
}
|
||||
prevServeConfig = sc
|
||||
}
|
||||
}
|
||||
|
||||
// readServeConfig reads the ipn.ServeConfig from path, replacing
|
||||
// ${TS_CERT_DOMAIN} with certDomain.
|
||||
func readServeConfig(path, certDomain string) (*ipn.ServeConfig, error) {
|
||||
if path == "" {
|
||||
return nil, nil
|
||||
}
|
||||
j, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
j = bytes.ReplaceAll(j, []byte("${TS_CERT_DOMAIN}"), []byte(certDomain))
|
||||
var sc ipn.ServeConfig
|
||||
if err := json.Unmarshal(j, &sc); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &sc, nil
|
||||
}
|
||||
|
||||
func startTailscaled(ctx context.Context, cfg *settings) (*tailscale.LocalClient, *os.Process, error) {
|
||||
args := tailscaledArgs(cfg)
|
||||
// tailscaled runs without context, since it needs to persist
|
||||
// beyond the startup timeout in ctx.
|
||||
cmd := exec.Command("tailscaled", args...)
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{
|
||||
Setpgid: true,
|
||||
}
|
||||
log.Printf("Starting tailscaled")
|
||||
if err := cmd.Start(); err != nil {
|
||||
return nil, nil, fmt.Errorf("starting tailscaled failed: %v", err)
|
||||
}
|
||||
|
||||
// Wait for the socket file to appear, otherwise API ops will racily fail.
|
||||
log.Printf("Waiting for tailscaled socket")
|
||||
for {
|
||||
if ctx.Err() != nil {
|
||||
log.Fatalf("Timed out waiting for tailscaled socket")
|
||||
}
|
||||
_, err := os.Stat(cfg.Socket)
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
continue
|
||||
} else if err != nil {
|
||||
log.Fatalf("Waiting for tailscaled socket: %v", err)
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
tsClient := &tailscale.LocalClient{
|
||||
Socket: cfg.Socket,
|
||||
UseSocketOnly: true,
|
||||
}
|
||||
|
||||
return tsClient, cmd.Process, nil
|
||||
}
|
||||
|
||||
// tailscaledArgs uses cfg to construct the argv for tailscaled.
|
||||
func tailscaledArgs(cfg *settings) []string {
|
||||
args := []string{"--socket=" + cfg.Socket}
|
||||
switch {
|
||||
case cfg.InKubernetes && cfg.KubeSecret != "":
|
||||
args = append(args, "--state=kube:"+cfg.KubeSecret)
|
||||
if cfg.StateDir == "" {
|
||||
cfg.StateDir = "/tmp"
|
||||
}
|
||||
fallthrough
|
||||
case cfg.StateDir != "":
|
||||
args = append(args, "--statedir="+cfg.StateDir)
|
||||
default:
|
||||
args = append(args, "--state=mem:", "--statedir=/tmp")
|
||||
}
|
||||
|
||||
if cfg.UserspaceMode {
|
||||
args = append(args, "--tun=userspace-networking")
|
||||
} else if err := ensureTunFile(cfg.Root); err != nil {
|
||||
log.Fatalf("ensuring that /dev/net/tun exists: %v", err)
|
||||
}
|
||||
|
||||
if cfg.SOCKSProxyAddr != "" {
|
||||
args = append(args, "--socks5-server="+cfg.SOCKSProxyAddr)
|
||||
}
|
||||
if cfg.HTTPProxyAddr != "" {
|
||||
args = append(args, "--outbound-http-proxy-listen="+cfg.HTTPProxyAddr)
|
||||
}
|
||||
if cfg.TailscaledConfigFilePath != "" {
|
||||
args = append(args, "--config="+cfg.TailscaledConfigFilePath)
|
||||
}
|
||||
if cfg.DaemonExtraArgs != "" {
|
||||
args = append(args, strings.Fields(cfg.DaemonExtraArgs)...)
|
||||
}
|
||||
return args
|
||||
}
|
||||
|
||||
// tailscaleUp uses cfg to run 'tailscale up' everytime containerboot starts, or
|
||||
// if TS_AUTH_ONCE is set, only the first time containerboot starts.
|
||||
func tailscaleUp(ctx context.Context, cfg *settings) error {
|
||||
args := []string{"--socket=" + cfg.Socket, "up"}
|
||||
if cfg.AcceptDNS != nil && *cfg.AcceptDNS {
|
||||
args = append(args, "--accept-dns=true")
|
||||
} else {
|
||||
args = append(args, "--accept-dns=false")
|
||||
}
|
||||
if cfg.AuthKey != "" {
|
||||
args = append(args, "--authkey="+cfg.AuthKey)
|
||||
}
|
||||
// --advertise-routes can be passed an empty string to configure a
|
||||
// device (that might have previously advertised subnet routes) to not
|
||||
// advertise any routes. Respect an empty string passed by a user and
|
||||
// use it to explicitly unset the routes.
|
||||
if cfg.Routes != nil {
|
||||
args = append(args, "--advertise-routes="+*cfg.Routes)
|
||||
}
|
||||
if cfg.Hostname != "" {
|
||||
args = append(args, "--hostname="+cfg.Hostname)
|
||||
}
|
||||
if cfg.ExtraArgs != "" {
|
||||
args = append(args, strings.Fields(cfg.ExtraArgs)...)
|
||||
}
|
||||
log.Printf("Running 'tailscale up'")
|
||||
cmd := exec.CommandContext(ctx, "tailscale", args...)
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
if err := cmd.Run(); err != nil {
|
||||
return fmt.Errorf("tailscale up failed: %v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// tailscaleSet uses cfg to run 'tailscale set' to set any known configuration
|
||||
// options that are passed in via environment variables. This is run after the
|
||||
// node is in Running state and only if TS_AUTH_ONCE is set.
|
||||
func tailscaleSet(ctx context.Context, cfg *settings) error {
|
||||
args := []string{"--socket=" + cfg.Socket, "set"}
|
||||
if cfg.AcceptDNS != nil && *cfg.AcceptDNS {
|
||||
args = append(args, "--accept-dns=true")
|
||||
} else {
|
||||
args = append(args, "--accept-dns=false")
|
||||
}
|
||||
// --advertise-routes can be passed an empty string to configure a
|
||||
// device (that might have previously advertised subnet routes) to not
|
||||
// advertise any routes. Respect an empty string passed by a user and
|
||||
// use it to explicitly unset the routes.
|
||||
if cfg.Routes != nil {
|
||||
args = append(args, "--advertise-routes="+*cfg.Routes)
|
||||
}
|
||||
if cfg.Hostname != "" {
|
||||
args = append(args, "--hostname="+cfg.Hostname)
|
||||
}
|
||||
log.Printf("Running 'tailscale set'")
|
||||
cmd := exec.CommandContext(ctx, "tailscale", args...)
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
if err := cmd.Run(); err != nil {
|
||||
return fmt.Errorf("tailscale set failed: %v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ensureTunFile checks that /dev/net/tun exists, creating it if
|
||||
// missing.
|
||||
func ensureTunFile(root string) error {
|
||||
@@ -818,340 +661,6 @@ func ensureTunFile(root string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// ensureIPForwarding enables IPv4/IPv6 forwarding for the container.
|
||||
func ensureIPForwarding(root, clusterProxyTargetIP, tailnetTargetIP, tailnetTargetFQDN string, routes *string) error {
|
||||
var (
|
||||
v4Forwarding, v6Forwarding bool
|
||||
)
|
||||
if clusterProxyTargetIP != "" {
|
||||
proxyIP, err := netip.ParseAddr(clusterProxyTargetIP)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid cluster destination IP: %v", err)
|
||||
}
|
||||
if proxyIP.Is4() {
|
||||
v4Forwarding = true
|
||||
} else {
|
||||
v6Forwarding = true
|
||||
}
|
||||
}
|
||||
if tailnetTargetIP != "" {
|
||||
proxyIP, err := netip.ParseAddr(tailnetTargetIP)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid tailnet destination IP: %v", err)
|
||||
}
|
||||
if proxyIP.Is4() {
|
||||
v4Forwarding = true
|
||||
} else {
|
||||
v6Forwarding = true
|
||||
}
|
||||
}
|
||||
// Currently we only proxy traffic to the IPv4 address of the tailnet
|
||||
// target.
|
||||
if tailnetTargetFQDN != "" {
|
||||
v4Forwarding = true
|
||||
}
|
||||
if routes != nil && *routes != "" {
|
||||
for _, route := range strings.Split(*routes, ",") {
|
||||
cidr, err := netip.ParsePrefix(route)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid subnet route: %v", err)
|
||||
}
|
||||
if cidr.Addr().Is4() {
|
||||
v4Forwarding = true
|
||||
} else {
|
||||
v6Forwarding = true
|
||||
}
|
||||
}
|
||||
}
|
||||
return enableIPForwarding(v4Forwarding, v6Forwarding, root)
|
||||
}
|
||||
|
||||
func enableIPForwarding(v4Forwarding, v6Forwarding bool, root string) error {
|
||||
var paths []string
|
||||
if v4Forwarding {
|
||||
paths = append(paths, filepath.Join(root, "proc/sys/net/ipv4/ip_forward"))
|
||||
}
|
||||
if v6Forwarding {
|
||||
paths = append(paths, filepath.Join(root, "proc/sys/net/ipv6/conf/all/forwarding"))
|
||||
}
|
||||
|
||||
// In some common configurations (e.g. default docker,
|
||||
// kubernetes), the container environment denies write access to
|
||||
// most sysctls, including IP forwarding controls. Check the
|
||||
// sysctl values before trying to change them, so that we
|
||||
// gracefully do nothing if the container's already been set up
|
||||
// properly by e.g. a k8s initContainer.
|
||||
for _, path := range paths {
|
||||
bs, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("reading %q: %w", path, err)
|
||||
}
|
||||
if v := strings.TrimSpace(string(bs)); v != "1" {
|
||||
if err := os.WriteFile(path, []byte("1"), 0644); err != nil {
|
||||
return fmt.Errorf("enabling %q: %w", path, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func installEgressForwardingRule(ctx context.Context, dstStr string, tsIPs []netip.Prefix, nfr linuxfw.NetfilterRunner) error {
|
||||
dst, err := netip.ParseAddr(dstStr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var local netip.Addr
|
||||
for _, pfx := range tsIPs {
|
||||
if !pfx.IsSingleIP() {
|
||||
continue
|
||||
}
|
||||
if pfx.Addr().Is4() != dst.Is4() {
|
||||
continue
|
||||
}
|
||||
local = pfx.Addr()
|
||||
break
|
||||
}
|
||||
if !local.IsValid() {
|
||||
return fmt.Errorf("no tailscale IP matching family of %s found in %v", dstStr, tsIPs)
|
||||
}
|
||||
if err := nfr.DNATNonTailscaleTraffic("tailscale0", dst); err != nil {
|
||||
return fmt.Errorf("installing egress proxy rules: %w", err)
|
||||
}
|
||||
if err := nfr.AddSNATRuleForDst(local, dst); err != nil {
|
||||
return fmt.Errorf("installing egress proxy rules: %w", err)
|
||||
}
|
||||
if err := nfr.ClampMSSToPMTU("tailscale0", dst); err != nil {
|
||||
return fmt.Errorf("installing egress proxy rules: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// installTSForwardingRuleForDestination accepts a destination address and a
|
||||
// list of node's tailnet addresses, sets up rules to forward traffic for
|
||||
// destination to the tailnet IP matching the destination IP family.
|
||||
// Destination can be Pod IP of this node.
|
||||
func installTSForwardingRuleForDestination(ctx context.Context, dstFilter string, tsIPs []netip.Prefix, nfr linuxfw.NetfilterRunner) error {
|
||||
dst, err := netip.ParseAddr(dstFilter)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var local netip.Addr
|
||||
for _, pfx := range tsIPs {
|
||||
if !pfx.IsSingleIP() {
|
||||
continue
|
||||
}
|
||||
if pfx.Addr().Is4() != dst.Is4() {
|
||||
continue
|
||||
}
|
||||
local = pfx.Addr()
|
||||
break
|
||||
}
|
||||
if !local.IsValid() {
|
||||
return fmt.Errorf("no tailscale IP matching family of %s found in %v", dstFilter, tsIPs)
|
||||
}
|
||||
if err := nfr.AddDNATRule(dst, local); err != nil {
|
||||
return fmt.Errorf("installing rule for forwarding traffic to tailnet IP: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func installIngressForwardingRule(ctx context.Context, dstStr string, tsIPs []netip.Prefix, nfr linuxfw.NetfilterRunner) error {
|
||||
dst, err := netip.ParseAddr(dstStr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var local netip.Addr
|
||||
proxyHasIPv4Address := false
|
||||
for _, pfx := range tsIPs {
|
||||
if !pfx.IsSingleIP() {
|
||||
continue
|
||||
}
|
||||
if pfx.Addr().Is4() {
|
||||
proxyHasIPv4Address = true
|
||||
}
|
||||
if pfx.Addr().Is4() != dst.Is4() {
|
||||
continue
|
||||
}
|
||||
local = pfx.Addr()
|
||||
break
|
||||
}
|
||||
if proxyHasIPv4Address && dst.Is6() {
|
||||
log.Printf("Warning: proxy backend ClusterIP is an IPv6 address and the proxy has a IPv4 tailnet address. You might need to disable IPv4 address allocation for the proxy for forwarding to work. See https://github.com/tailscale/tailscale/issues/12156")
|
||||
}
|
||||
if !local.IsValid() {
|
||||
return fmt.Errorf("no tailscale IP matching family of %s found in %v", dstStr, tsIPs)
|
||||
}
|
||||
if err := nfr.AddDNATRule(local, dst); err != nil {
|
||||
return fmt.Errorf("installing ingress proxy rules: %w", err)
|
||||
}
|
||||
if err := nfr.ClampMSSToPMTU("tailscale0", dst); err != nil {
|
||||
return fmt.Errorf("installing ingress proxy rules: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func installIngressForwardingRuleForDNSTarget(ctx context.Context, backendAddrs []net.IP, tsIPs []netip.Prefix, nfr linuxfw.NetfilterRunner) error {
|
||||
var (
|
||||
tsv4 netip.Addr
|
||||
tsv6 netip.Addr
|
||||
v4Backends []netip.Addr
|
||||
v6Backends []netip.Addr
|
||||
)
|
||||
for _, pfx := range tsIPs {
|
||||
if pfx.IsSingleIP() && pfx.Addr().Is4() {
|
||||
tsv4 = pfx.Addr()
|
||||
continue
|
||||
}
|
||||
if pfx.IsSingleIP() && pfx.Addr().Is6() {
|
||||
tsv6 = pfx.Addr()
|
||||
continue
|
||||
}
|
||||
}
|
||||
// TODO: log if more than one backend address is found and firewall is
|
||||
// in nftables mode that only the first IP will be used.
|
||||
for _, ip := range backendAddrs {
|
||||
if ip.To4() != nil {
|
||||
v4Backends = append(v4Backends, netip.AddrFrom4([4]byte(ip.To4())))
|
||||
}
|
||||
if ip.To16() != nil {
|
||||
v6Backends = append(v6Backends, netip.AddrFrom16([16]byte(ip.To16())))
|
||||
}
|
||||
}
|
||||
|
||||
// Enable IP forwarding here as opposed to at the start of containerboot
|
||||
// as the IPv4/IPv6 requirements might have changed.
|
||||
// For Kubernetes operator proxies, forwarding for both IPv4 and IPv6 is
|
||||
// enabled by an init container, so in practice enabling forwarding here
|
||||
// is only needed if this proxy has been configured by manually setting
|
||||
// TS_EXPERIMENTAL_DEST_DNS_NAME env var for a containerboot instance.
|
||||
if err := enableIPForwarding(len(v4Backends) != 0, len(v6Backends) != 0, ""); err != nil {
|
||||
log.Printf("[unexpected] failed to ensure IP forwarding: %v", err)
|
||||
}
|
||||
|
||||
updateFirewall := func(dst netip.Addr, backendTargets []netip.Addr) error {
|
||||
if err := nfr.DNATWithLoadBalancer(dst, backendTargets); err != nil {
|
||||
return fmt.Errorf("installing DNAT rules for ingress backends %+#v: %w", backendTargets, err)
|
||||
}
|
||||
// The backend might advertize MSS higher than that of the
|
||||
// tailscale interfaces. Clamp MSS of packets going out via
|
||||
// tailscale0 interface to its MTU to prevent broken connections
|
||||
// in environments where path MTU discovery is not working.
|
||||
if err := nfr.ClampMSSToPMTU("tailscale0", dst); err != nil {
|
||||
return fmt.Errorf("adding rule to clamp traffic via tailscale0: %v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
if len(v4Backends) != 0 {
|
||||
if !tsv4.IsValid() {
|
||||
log.Printf("backend targets %v contain at least one IPv4 address, but this node's Tailscale IPs do not contain a valid IPv4 address: %v", backendAddrs, tsIPs)
|
||||
} else if err := updateFirewall(tsv4, v4Backends); err != nil {
|
||||
return fmt.Errorf("Installing IPv4 firewall rules: %w", err)
|
||||
}
|
||||
}
|
||||
if len(v6Backends) != 0 && !tsv6.IsValid() {
|
||||
if !tsv6.IsValid() {
|
||||
log.Printf("backend targets %v contain at least one IPv6 address, but this node's Tailscale IPs do not contain a valid IPv6 address: %v", backendAddrs, tsIPs)
|
||||
} else if !nfr.HasIPV6NAT() {
|
||||
log.Printf("backend targets %v contain at least one IPv6 address, but the chosen firewall mode does not support IPv6 NAT", backendAddrs)
|
||||
} else if err := updateFirewall(tsv6, v6Backends); err != nil {
|
||||
return fmt.Errorf("Installing IPv6 firewall rules: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// settings is all the configuration for containerboot.
|
||||
type settings struct {
|
||||
AuthKey string
|
||||
Hostname string
|
||||
Routes *string
|
||||
// ProxyTargetIP is the destination IP to which all incoming
|
||||
// Tailscale traffic should be proxied. If empty, no proxying
|
||||
// is done. This is typically a locally reachable IP.
|
||||
ProxyTargetIP string
|
||||
// ProxyTargetDNSName is a DNS name to whose backing IP addresses all
|
||||
// incoming Tailscale traffic should be proxied.
|
||||
ProxyTargetDNSName string
|
||||
// TailnetTargetIP is the destination IP to which all incoming
|
||||
// non-Tailscale traffic should be proxied. This is typically a
|
||||
// Tailscale IP.
|
||||
TailnetTargetIP string
|
||||
// TailnetTargetFQDN is an MagicDNS name to which all incoming
|
||||
// non-Tailscale traffic should be proxied. This must be a full Tailnet
|
||||
// node FQDN.
|
||||
TailnetTargetFQDN string
|
||||
ServeConfigPath string
|
||||
DaemonExtraArgs string
|
||||
ExtraArgs string
|
||||
InKubernetes bool
|
||||
UserspaceMode bool
|
||||
StateDir string
|
||||
AcceptDNS *bool
|
||||
KubeSecret string
|
||||
SOCKSProxyAddr string
|
||||
HTTPProxyAddr string
|
||||
Socket string
|
||||
AuthOnce bool
|
||||
Root string
|
||||
KubernetesCanPatch bool
|
||||
TailscaledConfigFilePath string
|
||||
// If set to true and, if this containerboot instance is a Kubernetes
|
||||
// ingress proxy, set up rules to forward incoming cluster traffic to be
|
||||
// forwarded to the ingress target in cluster.
|
||||
AllowProxyingClusterTrafficViaIngress bool
|
||||
// PodIP is the IP of the Pod if running in Kubernetes. This is used
|
||||
// when setting up rules to proxy cluster traffic to cluster ingress
|
||||
// target.
|
||||
PodIP string
|
||||
}
|
||||
|
||||
func (s *settings) validate() error {
|
||||
if s.TailscaledConfigFilePath != "" {
|
||||
dir, file := path.Split(s.TailscaledConfigFilePath)
|
||||
if _, err := os.Stat(dir); err != nil {
|
||||
return fmt.Errorf("error validating whether directory with tailscaled config file %s exists: %w", dir, err)
|
||||
}
|
||||
if _, err := os.Stat(s.TailscaledConfigFilePath); err != nil {
|
||||
return fmt.Errorf("error validating whether tailscaled config directory %q contains tailscaled config for current capability version %q: %w. If this is a Tailscale Kubernetes operator proxy, please ensure that the version of the operator is not older than the version of the proxy", dir, file, err)
|
||||
}
|
||||
if _, err := conffile.Load(s.TailscaledConfigFilePath); err != nil {
|
||||
return fmt.Errorf("error validating tailscaled configfile contents: %w", err)
|
||||
}
|
||||
}
|
||||
if s.ProxyTargetIP != "" && s.UserspaceMode {
|
||||
return errors.New("TS_DEST_IP is not supported with TS_USERSPACE")
|
||||
}
|
||||
if s.ProxyTargetDNSName != "" && s.UserspaceMode {
|
||||
return errors.New("TS_EXPERIMENTAL_DEST_DNS_NAME is not supported with TS_USERSPACE")
|
||||
}
|
||||
if s.ProxyTargetDNSName != "" && s.ProxyTargetIP != "" {
|
||||
return errors.New("TS_EXPERIMENTAL_DEST_DNS_NAME and TS_DEST_IP cannot both be set")
|
||||
}
|
||||
if s.TailnetTargetIP != "" && s.UserspaceMode {
|
||||
return errors.New("TS_TAILNET_TARGET_IP is not supported with TS_USERSPACE")
|
||||
}
|
||||
if s.TailnetTargetFQDN != "" && s.UserspaceMode {
|
||||
return errors.New("TS_TAILNET_TARGET_FQDN is not supported with TS_USERSPACE")
|
||||
}
|
||||
if s.TailnetTargetFQDN != "" && s.TailnetTargetIP != "" {
|
||||
return errors.New("Both TS_TAILNET_TARGET_IP and TS_TAILNET_FQDN cannot be set")
|
||||
}
|
||||
if s.TailscaledConfigFilePath != "" && (s.AcceptDNS != nil || s.AuthKey != "" || s.Routes != nil || s.ExtraArgs != "" || s.Hostname != "") {
|
||||
return errors.New("TS_EXPERIMENTAL_VERSIONED_CONFIG_DIR cannot be set in combination with TS_HOSTNAME, TS_EXTRA_ARGS, TS_AUTHKEY, TS_ROUTES, TS_ACCEPT_DNS.")
|
||||
}
|
||||
if s.AllowProxyingClusterTrafficViaIngress && s.UserspaceMode {
|
||||
return errors.New("EXPERIMENTAL_ALLOW_PROXYING_CLUSTER_TRAFFIC_VIA_INGRESS is not supported in userspace mode")
|
||||
}
|
||||
if s.AllowProxyingClusterTrafficViaIngress && s.ServeConfigPath == "" {
|
||||
return errors.New("EXPERIMENTAL_ALLOW_PROXYING_CLUSTER_TRAFFIC_VIA_INGRESS is set but this is not a cluster ingress proxy")
|
||||
}
|
||||
if s.AllowProxyingClusterTrafficViaIngress && s.PodIP == "" {
|
||||
return errors.New("EXPERIMENTAL_ALLOW_PROXYING_CLUSTER_TRAFFIC_VIA_INGRESS is set but POD_IP is not set")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func resolveDNS(ctx context.Context, name string) ([]net.IP, error) {
|
||||
// TODO (irbekrm): look at using recursive.Resolver instead to resolve
|
||||
// the DNS names as well as retrieve TTLs. It looks though that this
|
||||
@@ -1174,57 +683,6 @@ func resolveDNS(ctx context.Context, name string) ([]net.IP, error) {
|
||||
return append(ip4s, ip6s...), nil
|
||||
}
|
||||
|
||||
// defaultEnv returns the value of the given envvar name, or defVal if
|
||||
// unset.
|
||||
func defaultEnv(name, defVal string) string {
|
||||
if v, ok := os.LookupEnv(name); ok {
|
||||
return v
|
||||
}
|
||||
return defVal
|
||||
}
|
||||
|
||||
// defaultEnvStringPointer returns a pointer to the given envvar value if set, else
|
||||
// returns nil. This is useful in cases where we need to distinguish between a
|
||||
// variable being set to empty string vs unset.
|
||||
func defaultEnvStringPointer(name string) *string {
|
||||
if v, ok := os.LookupEnv(name); ok {
|
||||
return &v
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// defaultEnvBoolPointer returns a pointer to the given envvar value if set, else
|
||||
// returns nil. This is useful in cases where we need to distinguish between a
|
||||
// variable being explicitly set to false vs unset.
|
||||
func defaultEnvBoolPointer(name string) *bool {
|
||||
v := os.Getenv(name)
|
||||
ret, err := strconv.ParseBool(v)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return &ret
|
||||
}
|
||||
|
||||
func defaultEnvs(names []string, defVal string) string {
|
||||
for _, name := range names {
|
||||
if v, ok := os.LookupEnv(name); ok {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return defVal
|
||||
}
|
||||
|
||||
// defaultBool returns the boolean value of the given envvar name, or
|
||||
// defVal if unset or not a bool.
|
||||
func defaultBool(name string, defVal bool) bool {
|
||||
v := os.Getenv(name)
|
||||
ret, err := strconv.ParseBool(v)
|
||||
if err != nil {
|
||||
return defVal
|
||||
}
|
||||
return ret
|
||||
}
|
||||
|
||||
// contextWithExitSignalWatch watches for SIGTERM/SIGINT signals. It returns a
|
||||
// context that gets cancelled when a signal is received and a cancel function
|
||||
// that can be called to free the resources when the watch should be stopped.
|
||||
@@ -1247,30 +705,6 @@ func contextWithExitSignalWatch() (context.Context, func()) {
|
||||
return ctx, f
|
||||
}
|
||||
|
||||
// isTwoStepConfigAuthOnce returns true if the Tailscale node should be configured
|
||||
// in two steps and login should only happen once.
|
||||
// Step 1: run 'tailscaled'
|
||||
// Step 2):
|
||||
// A) if this is the first time starting this node run 'tailscale up --authkey <authkey> <config opts>'
|
||||
// B) if this is not the first time starting this node run 'tailscale set <config opts>'.
|
||||
func isTwoStepConfigAuthOnce(cfg *settings) bool {
|
||||
return cfg.AuthOnce && cfg.TailscaledConfigFilePath == ""
|
||||
}
|
||||
|
||||
// isTwoStepConfigAlwaysAuth returns true if the Tailscale node should be configured
|
||||
// in two steps and we should log in every time it starts.
|
||||
// Step 1: run 'tailscaled'
|
||||
// Step 2): run 'tailscale up --authkey <authkey> <config opts>'
|
||||
func isTwoStepConfigAlwaysAuth(cfg *settings) bool {
|
||||
return !cfg.AuthOnce && cfg.TailscaledConfigFilePath == ""
|
||||
}
|
||||
|
||||
// isOneStepConfig returns true if the Tailscale node should always be ran and
|
||||
// configured in a single step by running 'tailscaled <config opts>'
|
||||
func isOneStepConfig(cfg *settings) bool {
|
||||
return cfg.TailscaledConfigFilePath != ""
|
||||
}
|
||||
|
||||
// tailscaledConfigFilePath returns the path to the tailscaled config file that
|
||||
// should be used for the current capability version. It is determined by the
|
||||
// TS_EXPERIMENTAL_VERSIONED_CONFIG_DIR environment variable and looks for a
|
||||
@@ -1296,7 +730,6 @@ func tailscaledConfigFilePath() string {
|
||||
}
|
||||
cv, err := kubeutils.CapVerFromFileName(e.Name())
|
||||
if err != nil {
|
||||
log.Printf("skipping file %q in tailscaled config directory %q: %v", e.Name(), dir, err)
|
||||
continue
|
||||
}
|
||||
if cv > maxCompatVer && cv <= tailcfg.CurrentCapabilityVersion {
|
||||
@@ -1304,8 +737,9 @@ func tailscaledConfigFilePath() string {
|
||||
}
|
||||
}
|
||||
if maxCompatVer == -1 {
|
||||
log.Fatalf("no tailscaled config file found in %q for current capability version %q", dir, tailcfg.CurrentCapabilityVersion)
|
||||
log.Fatalf("no tailscaled config file found in %q for current capability version %d", dir, tailcfg.CurrentCapabilityVersion)
|
||||
}
|
||||
log.Printf("Using tailscaled config file %q for capability version %q", maxCompatVer, tailcfg.CurrentCapabilityVersion)
|
||||
return path.Join(dir, kubeutils.TailscaledConfigFileNameForCap(maxCompatVer))
|
||||
filePath := filepath.Join(dir, kubeutils.TailscaledConfigFileName(maxCompatVer))
|
||||
log.Printf("Using tailscaled config file %q to match current capability version %d", filePath, tailcfg.CurrentCapabilityVersion)
|
||||
return filePath
|
||||
}
|
||||
|
||||
@@ -52,7 +52,7 @@ func TestContainerBoot(t *testing.T) {
|
||||
}
|
||||
defer kube.Close()
|
||||
|
||||
tailscaledConf := &ipn.ConfigVAlpha{AuthKey: func(s string) *string { return &s }("foo"), Version: "alpha0"}
|
||||
tailscaledConf := &ipn.ConfigVAlpha{AuthKey: ptr.To("foo"), Version: "alpha0"}
|
||||
tailscaledConfBytes, err := json.Marshal(tailscaledConf)
|
||||
if err != nil {
|
||||
t.Fatalf("error unmarshaling tailscaled config: %v", err)
|
||||
@@ -116,6 +116,9 @@ func TestContainerBoot(t *testing.T) {
|
||||
// WantFiles files that should exist in the container and their
|
||||
// contents.
|
||||
WantFiles map[string]string
|
||||
// WantFatalLog is the fatal log message we expect from containerboot.
|
||||
// If set for a phase, the test will finish on that phase.
|
||||
WantFatalLog string
|
||||
}
|
||||
runningNotify := &ipn.Notify{
|
||||
State: ptr.To(ipn.Running),
|
||||
@@ -349,12 +352,57 @@ func TestContainerBoot(t *testing.T) {
|
||||
"/usr/bin/tailscaled --socket=/tmp/tailscaled.sock --state=mem: --statedir=/tmp",
|
||||
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock up --accept-dns=false --authkey=tskey-key",
|
||||
},
|
||||
WantFiles: map[string]string{
|
||||
"proc/sys/net/ipv4/ip_forward": "1",
|
||||
"proc/sys/net/ipv6/conf/all/forwarding": "0",
|
||||
},
|
||||
},
|
||||
{
|
||||
Notify: runningNotify,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "egress_proxy_fqdn_ipv6_target_on_ipv4_host",
|
||||
Env: map[string]string{
|
||||
"TS_AUTHKEY": "tskey-key",
|
||||
"TS_TAILNET_TARGET_FQDN": "ipv6-node.test.ts.net", // resolves to IPv6 address
|
||||
"TS_USERSPACE": "false",
|
||||
"TS_TEST_FAKE_NETFILTER_6": "false",
|
||||
},
|
||||
Phases: []phase{
|
||||
{
|
||||
WantCmds: []string{
|
||||
"/usr/bin/tailscaled --socket=/tmp/tailscaled.sock --state=mem: --statedir=/tmp",
|
||||
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock up --accept-dns=false --authkey=tskey-key",
|
||||
},
|
||||
WantFiles: map[string]string{
|
||||
"proc/sys/net/ipv4/ip_forward": "1",
|
||||
"proc/sys/net/ipv6/conf/all/forwarding": "0",
|
||||
},
|
||||
},
|
||||
{
|
||||
Notify: &ipn.Notify{
|
||||
State: ptr.To(ipn.Running),
|
||||
NetMap: &netmap.NetworkMap{
|
||||
SelfNode: (&tailcfg.Node{
|
||||
StableID: tailcfg.StableNodeID("myID"),
|
||||
Name: "test-node.test.ts.net",
|
||||
Addresses: []netip.Prefix{netip.MustParsePrefix("100.64.0.1/32")},
|
||||
}).View(),
|
||||
Peers: []tailcfg.NodeView{
|
||||
(&tailcfg.Node{
|
||||
StableID: tailcfg.StableNodeID("ipv6ID"),
|
||||
Name: "ipv6-node.test.ts.net",
|
||||
Addresses: []netip.Prefix{netip.MustParsePrefix("::1/128")},
|
||||
}).View(),
|
||||
},
|
||||
},
|
||||
},
|
||||
WantFatalLog: "no forwarding rules for egress addresses [::1/128], host supports IPv6: false",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "authkey_once",
|
||||
Env: map[string]string{
|
||||
@@ -697,6 +745,25 @@ func TestContainerBoot(t *testing.T) {
|
||||
var wantCmds []string
|
||||
for i, p := range test.Phases {
|
||||
lapi.Notify(p.Notify)
|
||||
if p.WantFatalLog != "" {
|
||||
err := tstest.WaitFor(2*time.Second, func() error {
|
||||
state, err := cmd.Process.Wait()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if state.ExitCode() != 1 {
|
||||
return fmt.Errorf("process exited with code %d but wanted %d", state.ExitCode(), 1)
|
||||
}
|
||||
waitLogLine(t, time.Second, cbOut, p.WantFatalLog)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Early test return, we don't expect the successful startup log message.
|
||||
return
|
||||
}
|
||||
wantCmds = append(wantCmds, p.WantCmds...)
|
||||
waitArgs(t, 2*time.Second, d, argFile, strings.Join(wantCmds, "\n"))
|
||||
err := tstest.WaitFor(2*time.Second, func() error {
|
||||
|
||||
96
cmd/containerboot/serve.go
Normal file
96
cmd/containerboot/serve.go
Normal file
@@ -0,0 +1,96 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
//go:build linux
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/fsnotify/fsnotify"
|
||||
"tailscale.com/client/tailscale"
|
||||
"tailscale.com/ipn"
|
||||
)
|
||||
|
||||
// watchServeConfigChanges watches path for changes, and when it sees one, reads
|
||||
// the serve config from it, replacing ${TS_CERT_DOMAIN} with certDomain, and
|
||||
// applies it to lc. It exits when ctx is canceled. cdChanged is a channel that
|
||||
// is written to when the certDomain changes, causing the serve config to be
|
||||
// re-read and applied.
|
||||
func watchServeConfigChanges(ctx context.Context, path string, cdChanged <-chan bool, certDomainAtomic *atomic.Pointer[string], lc *tailscale.LocalClient) {
|
||||
if certDomainAtomic == nil {
|
||||
panic("cd must not be nil")
|
||||
}
|
||||
var tickChan <-chan time.Time
|
||||
var eventChan <-chan fsnotify.Event
|
||||
if w, err := fsnotify.NewWatcher(); err != nil {
|
||||
log.Printf("failed to create fsnotify watcher, timer-only mode: %v", err)
|
||||
ticker := time.NewTicker(5 * time.Second)
|
||||
defer ticker.Stop()
|
||||
tickChan = ticker.C
|
||||
} else {
|
||||
defer w.Close()
|
||||
if err := w.Add(filepath.Dir(path)); err != nil {
|
||||
log.Fatalf("failed to add fsnotify watch: %v", err)
|
||||
}
|
||||
eventChan = w.Events
|
||||
}
|
||||
|
||||
var certDomain string
|
||||
var prevServeConfig *ipn.ServeConfig
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-cdChanged:
|
||||
certDomain = *certDomainAtomic.Load()
|
||||
case <-tickChan:
|
||||
case <-eventChan:
|
||||
// We can't do any reasonable filtering on the event because of how
|
||||
// k8s handles these mounts. So just re-read the file and apply it
|
||||
// if it's changed.
|
||||
}
|
||||
if certDomain == "" {
|
||||
continue
|
||||
}
|
||||
sc, err := readServeConfig(path, certDomain)
|
||||
if err != nil {
|
||||
log.Fatalf("failed to read serve config: %v", err)
|
||||
}
|
||||
if prevServeConfig != nil && reflect.DeepEqual(sc, prevServeConfig) {
|
||||
continue
|
||||
}
|
||||
log.Printf("Applying serve config")
|
||||
if err := lc.SetServeConfig(ctx, sc); err != nil {
|
||||
log.Fatalf("failed to set serve config: %v", err)
|
||||
}
|
||||
prevServeConfig = sc
|
||||
}
|
||||
}
|
||||
|
||||
// readServeConfig reads the ipn.ServeConfig from path, replacing
|
||||
// ${TS_CERT_DOMAIN} with certDomain.
|
||||
func readServeConfig(path, certDomain string) (*ipn.ServeConfig, error) {
|
||||
if path == "" {
|
||||
return nil, nil
|
||||
}
|
||||
j, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
j = bytes.ReplaceAll(j, []byte("${TS_CERT_DOMAIN}"), []byte(certDomain))
|
||||
var sc ipn.ServeConfig
|
||||
if err := json.Unmarshal(j, &sc); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &sc, nil
|
||||
}
|
||||
571
cmd/containerboot/services.go
Normal file
571
cmd/containerboot/services.go
Normal file
@@ -0,0 +1,571 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
//go:build linux
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/netip"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/fsnotify/fsnotify"
|
||||
"tailscale.com/ipn"
|
||||
"tailscale.com/kube/egressservices"
|
||||
"tailscale.com/kube/kubeclient"
|
||||
"tailscale.com/tailcfg"
|
||||
"tailscale.com/util/linuxfw"
|
||||
"tailscale.com/util/mak"
|
||||
)
|
||||
|
||||
const tailscaleTunInterface = "tailscale0"
|
||||
|
||||
// This file contains functionality to run containerboot as a proxy that can
|
||||
// route cluster traffic to one or more tailnet targets, based on portmapping
|
||||
// rules read from a configfile. Currently (9/2024) this is only used for the
|
||||
// Kubernetes operator egress proxies.
|
||||
|
||||
// egressProxy knows how to configure firewall rules to route cluster traffic to
|
||||
// one or more tailnet services.
|
||||
type egressProxy struct {
|
||||
cfgPath string // path to egress service config file
|
||||
|
||||
nfr linuxfw.NetfilterRunner // never nil
|
||||
|
||||
kc kubeclient.Client // never nil
|
||||
stateSecret string // name of the kube state Secret
|
||||
|
||||
netmapChan chan ipn.Notify // chan to receive netmap updates on
|
||||
|
||||
podIPv4 string // never empty string, currently only IPv4 is supported
|
||||
|
||||
// tailnetFQDNs is the egress service FQDN to tailnet IP mappings that
|
||||
// were last used to configure firewall rules for this proxy.
|
||||
// TODO(irbekrm): target addresses are also stored in the state Secret.
|
||||
// Evaluate whether we should retrieve them from there and not store in
|
||||
// memory at all.
|
||||
targetFQDNs map[string][]netip.Prefix
|
||||
|
||||
// used to configure firewall rules.
|
||||
tailnetAddrs []netip.Prefix
|
||||
}
|
||||
|
||||
// run configures egress proxy firewall rules and ensures that the firewall rules are reconfigured when:
|
||||
// - the mounted egress config has changed
|
||||
// - the proxy's tailnet IP addresses have changed
|
||||
// - tailnet IPs have changed for any backend targets specified by tailnet FQDN
|
||||
func (ep *egressProxy) run(ctx context.Context, n ipn.Notify) error {
|
||||
var tickChan <-chan time.Time
|
||||
var eventChan <-chan fsnotify.Event
|
||||
// TODO (irbekrm): take a look if this can be pulled into a single func
|
||||
// shared with serve config loader.
|
||||
if w, err := fsnotify.NewWatcher(); err != nil {
|
||||
log.Printf("failed to create fsnotify watcher, timer-only mode: %v", err)
|
||||
ticker := time.NewTicker(5 * time.Second)
|
||||
defer ticker.Stop()
|
||||
tickChan = ticker.C
|
||||
} else {
|
||||
defer w.Close()
|
||||
if err := w.Add(filepath.Dir(ep.cfgPath)); err != nil {
|
||||
return fmt.Errorf("failed to add fsnotify watch: %w", err)
|
||||
}
|
||||
eventChan = w.Events
|
||||
}
|
||||
|
||||
if err := ep.sync(ctx, n); err != nil {
|
||||
return err
|
||||
}
|
||||
for {
|
||||
var err error
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil
|
||||
case <-tickChan:
|
||||
err = ep.sync(ctx, n)
|
||||
case <-eventChan:
|
||||
log.Printf("config file change detected, ensuring firewall config is up to date...")
|
||||
err = ep.sync(ctx, n)
|
||||
case n = <-ep.netmapChan:
|
||||
shouldResync := ep.shouldResync(n)
|
||||
if shouldResync {
|
||||
log.Printf("netmap change detected, ensuring firewall config is up to date...")
|
||||
err = ep.sync(ctx, n)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("error syncing egress service config: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// sync triggers an egress proxy config resync. The resync calculates the diff between config and status to determine if
|
||||
// any firewall rules need to be updated. Currently using status in state Secret as a reference for what is the current
|
||||
// firewall configuration is good enough because - the status is keyed by the Pod IP - we crash the Pod on errors such
|
||||
// as failed firewall update
|
||||
func (ep *egressProxy) sync(ctx context.Context, n ipn.Notify) error {
|
||||
cfgs, err := ep.getConfigs()
|
||||
if err != nil {
|
||||
return fmt.Errorf("error retrieving egress service configs: %w", err)
|
||||
}
|
||||
status, err := ep.getStatus(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error retrieving current egress proxy status: %w", err)
|
||||
}
|
||||
newStatus, err := ep.syncEgressConfigs(cfgs, status, n)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error syncing egress service configs: %w", err)
|
||||
}
|
||||
if !servicesStatusIsEqual(newStatus, status) {
|
||||
if err := ep.setStatus(ctx, newStatus, n); err != nil {
|
||||
return fmt.Errorf("error setting egress proxy status: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// addrsHaveChanged returns true if the provided netmap update contains tailnet address change for this proxy node.
|
||||
// Netmap must not be nil.
|
||||
func (ep *egressProxy) addrsHaveChanged(n ipn.Notify) bool {
|
||||
return !reflect.DeepEqual(ep.tailnetAddrs, n.NetMap.SelfNode.Addresses())
|
||||
}
|
||||
|
||||
// syncEgressConfigs adds and deletes firewall rules to match the desired
|
||||
// configuration. It uses the provided status to determine what is currently
|
||||
// applied and updates the status after a successful sync.
|
||||
func (ep *egressProxy) syncEgressConfigs(cfgs *egressservices.Configs, status *egressservices.Status, n ipn.Notify) (*egressservices.Status, error) {
|
||||
if !(wantsServicesConfigured(cfgs) || hasServicesConfigured(status)) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Delete unnecessary services.
|
||||
if err := ep.deleteUnnecessaryServices(cfgs, status); err != nil {
|
||||
return nil, fmt.Errorf("error deleting services: %w", err)
|
||||
|
||||
}
|
||||
newStatus := &egressservices.Status{}
|
||||
if !wantsServicesConfigured(cfgs) {
|
||||
return newStatus, nil
|
||||
}
|
||||
|
||||
// Add new services, update rules for any that have changed.
|
||||
rulesPerSvcToAdd := make(map[string][]rule, 0)
|
||||
rulesPerSvcToDelete := make(map[string][]rule, 0)
|
||||
for svcName, cfg := range *cfgs {
|
||||
tailnetTargetIPs, err := ep.tailnetTargetIPsForSvc(cfg, n)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error determining tailnet target IPs: %w", err)
|
||||
}
|
||||
rulesToAdd, rulesToDelete, err := updatesForCfg(svcName, cfg, status, tailnetTargetIPs)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error validating service changes: %v", err)
|
||||
}
|
||||
log.Printf("syncegressservices: looking at svc %s rulesToAdd %d rulesToDelete %d", svcName, len(rulesToAdd), len(rulesToDelete))
|
||||
if len(rulesToAdd) != 0 {
|
||||
mak.Set(&rulesPerSvcToAdd, svcName, rulesToAdd)
|
||||
}
|
||||
if len(rulesToDelete) != 0 {
|
||||
mak.Set(&rulesPerSvcToDelete, svcName, rulesToDelete)
|
||||
}
|
||||
if len(rulesToAdd) != 0 || ep.addrsHaveChanged(n) {
|
||||
// For each tailnet target, set up SNAT from the local tailnet device address of the matching
|
||||
// family.
|
||||
for _, t := range tailnetTargetIPs {
|
||||
var local netip.Addr
|
||||
for _, pfx := range n.NetMap.SelfNode.Addresses().All() {
|
||||
if !pfx.IsSingleIP() {
|
||||
continue
|
||||
}
|
||||
if pfx.Addr().Is4() != t.Is4() {
|
||||
continue
|
||||
}
|
||||
local = pfx.Addr()
|
||||
break
|
||||
}
|
||||
if !local.IsValid() {
|
||||
return nil, fmt.Errorf("no valid local IP: %v", local)
|
||||
}
|
||||
if err := ep.nfr.EnsureSNATForDst(local, t); err != nil {
|
||||
return nil, fmt.Errorf("error setting up SNAT rule: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Update the status. Status will be written back to the state Secret by the caller.
|
||||
mak.Set(&newStatus.Services, svcName, &egressservices.ServiceStatus{TailnetTargetIPs: tailnetTargetIPs, TailnetTarget: cfg.TailnetTarget, Ports: cfg.Ports})
|
||||
}
|
||||
|
||||
// Actually apply the firewall rules.
|
||||
if err := ensureRulesAdded(rulesPerSvcToAdd, ep.nfr); err != nil {
|
||||
return nil, fmt.Errorf("error adding rules: %w", err)
|
||||
}
|
||||
if err := ensureRulesDeleted(rulesPerSvcToDelete, ep.nfr); err != nil {
|
||||
return nil, fmt.Errorf("error deleting rules: %w", err)
|
||||
}
|
||||
|
||||
return newStatus, nil
|
||||
}
|
||||
|
||||
// updatesForCfg calculates any rules that need to be added or deleted for an individucal egress service config.
|
||||
func updatesForCfg(svcName string, cfg egressservices.Config, status *egressservices.Status, tailnetTargetIPs []netip.Addr) ([]rule, []rule, error) {
|
||||
rulesToAdd := make([]rule, 0)
|
||||
rulesToDelete := make([]rule, 0)
|
||||
currentConfig, ok := lookupCurrentConfig(svcName, status)
|
||||
|
||||
// If no rules for service are present yet, add them all.
|
||||
if !ok {
|
||||
for _, t := range tailnetTargetIPs {
|
||||
for ports := range cfg.Ports {
|
||||
log.Printf("syncegressservices: svc %s adding port %v", svcName, ports)
|
||||
rulesToAdd = append(rulesToAdd, rule{tailnetPort: ports.TargetPort, containerPort: ports.MatchPort, protocol: ports.Protocol, tailnetIP: t})
|
||||
}
|
||||
}
|
||||
return rulesToAdd, rulesToDelete, nil
|
||||
}
|
||||
|
||||
// If there are no backend targets available, delete any currently configured rules.
|
||||
if len(tailnetTargetIPs) == 0 {
|
||||
log.Printf("tailnet target for egress service %s does not have any backend addresses, deleting all rules", svcName)
|
||||
for _, ip := range currentConfig.TailnetTargetIPs {
|
||||
for ports := range currentConfig.Ports {
|
||||
rulesToDelete = append(rulesToAdd, rule{tailnetPort: ports.TargetPort, containerPort: ports.MatchPort, protocol: ports.Protocol, tailnetIP: ip})
|
||||
}
|
||||
}
|
||||
return rulesToAdd, rulesToDelete, nil
|
||||
}
|
||||
|
||||
// If there are rules present for backend targets that no longer match, delete them.
|
||||
for _, ip := range currentConfig.TailnetTargetIPs {
|
||||
var found bool
|
||||
for _, wantsIP := range tailnetTargetIPs {
|
||||
if reflect.DeepEqual(ip, wantsIP) {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
for ports := range currentConfig.Ports {
|
||||
rulesToDelete = append(rulesToDelete, rule{tailnetPort: ports.TargetPort, containerPort: ports.MatchPort, protocol: ports.Protocol, tailnetIP: ip})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sync rules for the currently wanted backend targets.
|
||||
for _, ip := range tailnetTargetIPs {
|
||||
|
||||
// If the backend target is not yet present in status, add all rules.
|
||||
var found bool
|
||||
for _, gotIP := range currentConfig.TailnetTargetIPs {
|
||||
if reflect.DeepEqual(ip, gotIP) {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
for ports := range cfg.Ports {
|
||||
rulesToAdd = append(rulesToAdd, rule{tailnetPort: ports.TargetPort, containerPort: ports.MatchPort, protocol: ports.Protocol, tailnetIP: ip})
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// If the backend target is present in status, check that the
|
||||
// currently applied rules are up to date.
|
||||
|
||||
// Delete any current portmappings that are no longer present in config.
|
||||
for port := range currentConfig.Ports {
|
||||
if _, ok := cfg.Ports[port]; ok {
|
||||
continue
|
||||
}
|
||||
rulesToDelete = append(rulesToDelete, rule{tailnetPort: port.TargetPort, containerPort: port.MatchPort, protocol: port.Protocol, tailnetIP: ip})
|
||||
}
|
||||
|
||||
// Add any new portmappings.
|
||||
for port := range cfg.Ports {
|
||||
if _, ok := currentConfig.Ports[port]; ok {
|
||||
continue
|
||||
}
|
||||
rulesToAdd = append(rulesToAdd, rule{tailnetPort: port.TargetPort, containerPort: port.MatchPort, protocol: port.Protocol, tailnetIP: ip})
|
||||
}
|
||||
}
|
||||
return rulesToAdd, rulesToDelete, nil
|
||||
}
|
||||
|
||||
// deleteUnneccessaryServices ensure that any services found on status, but not
|
||||
// present in config are deleted.
|
||||
func (ep *egressProxy) deleteUnnecessaryServices(cfgs *egressservices.Configs, status *egressservices.Status) error {
|
||||
if !hasServicesConfigured(status) {
|
||||
return nil
|
||||
}
|
||||
if !wantsServicesConfigured(cfgs) {
|
||||
for svcName, svc := range status.Services {
|
||||
log.Printf("service %s is no longer required, deleting", svcName)
|
||||
if err := ensureServiceDeleted(svcName, svc, ep.nfr); err != nil {
|
||||
return fmt.Errorf("error deleting service %s: %w", svcName, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
for svcName, svc := range status.Services {
|
||||
if _, ok := (*cfgs)[svcName]; !ok {
|
||||
log.Printf("service %s is no longer required, deleting", svcName)
|
||||
if err := ensureServiceDeleted(svcName, svc, ep.nfr); err != nil {
|
||||
return fmt.Errorf("error deleting service %s: %w", svcName, err)
|
||||
}
|
||||
// TODO (irbekrm): also delete the SNAT rule here
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// getConfigs gets the mounted egress service configuration.
|
||||
func (ep *egressProxy) getConfigs() (*egressservices.Configs, error) {
|
||||
j, err := os.ReadFile(ep.cfgPath)
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(j) == 0 || string(j) == "" {
|
||||
return nil, nil
|
||||
}
|
||||
cfg := &egressservices.Configs{}
|
||||
if err := json.Unmarshal(j, &cfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// getStatus gets the current status of the configured firewall. The current
|
||||
// status is stored in state Secret. Returns nil status if no status that
|
||||
// applies to the current proxy Pod was found. Uses the Pod IP to determine if a
|
||||
// status found in the state Secret applies to this proxy Pod.
|
||||
func (ep *egressProxy) getStatus(ctx context.Context) (*egressservices.Status, error) {
|
||||
secret, err := ep.kc.GetSecret(ctx, ep.stateSecret)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error retrieving state secret: %w", err)
|
||||
}
|
||||
status := &egressservices.Status{}
|
||||
raw, ok := secret.Data[egressservices.KeyEgressServices]
|
||||
if !ok {
|
||||
return nil, nil
|
||||
}
|
||||
if err := json.Unmarshal([]byte(raw), status); err != nil {
|
||||
return nil, fmt.Errorf("error unmarshalling previous config: %w", err)
|
||||
}
|
||||
if reflect.DeepEqual(status.PodIPv4, ep.podIPv4) {
|
||||
return status, nil
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// setStatus writes egress proxy's currently configured firewall to the state
|
||||
// Secret and updates proxy's tailnet addresses.
|
||||
func (ep *egressProxy) setStatus(ctx context.Context, status *egressservices.Status, n ipn.Notify) error {
|
||||
// Pod IP is used to determine if a stored status applies to THIS proxy Pod.
|
||||
if status == nil {
|
||||
status = &egressservices.Status{}
|
||||
}
|
||||
status.PodIPv4 = ep.podIPv4
|
||||
secret, err := ep.kc.GetSecret(ctx, ep.stateSecret)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error retrieving state Secret: %w", err)
|
||||
}
|
||||
bs, err := json.Marshal(status)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error marshalling service config: %w", err)
|
||||
}
|
||||
secret.Data[egressservices.KeyEgressServices] = bs
|
||||
patch := kubeclient.JSONPatch{
|
||||
Op: "replace",
|
||||
Path: fmt.Sprintf("/data/%s", egressservices.KeyEgressServices),
|
||||
Value: bs,
|
||||
}
|
||||
if err := ep.kc.JSONPatchResource(ctx, ep.stateSecret, kubeclient.TypeSecrets, []kubeclient.JSONPatch{patch}); err != nil {
|
||||
return fmt.Errorf("error patching state Secret: %w", err)
|
||||
}
|
||||
ep.tailnetAddrs = n.NetMap.SelfNode.Addresses().AsSlice()
|
||||
return nil
|
||||
}
|
||||
|
||||
// tailnetTargetIPsForSvc returns the tailnet IPs to which traffic for this
|
||||
// egress service should be proxied. The egress service can be configured by IP
|
||||
// or by FQDN. If it's configured by IP, just return that. If it's configured by
|
||||
// FQDN, resolve the FQDN and return the resolved IPs. It checks if the
|
||||
// netfilter runner supports IPv6 NAT and skips any IPv6 addresses if it
|
||||
// doesn't.
|
||||
func (ep *egressProxy) tailnetTargetIPsForSvc(svc egressservices.Config, n ipn.Notify) (addrs []netip.Addr, err error) {
|
||||
if svc.TailnetTarget.IP != "" {
|
||||
addr, err := netip.ParseAddr(svc.TailnetTarget.IP)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error parsing tailnet target IP: %w", err)
|
||||
}
|
||||
if addr.Is6() && !ep.nfr.HasIPV6NAT() {
|
||||
log.Printf("tailnet target is an IPv6 address, but this host does not support IPv6 in the chosen firewall mode. This will probably not work.")
|
||||
return addrs, nil
|
||||
}
|
||||
return []netip.Addr{addr}, nil
|
||||
}
|
||||
|
||||
if svc.TailnetTarget.FQDN == "" {
|
||||
return nil, errors.New("unexpected egress service config- neither tailnet target IP nor FQDN is set")
|
||||
}
|
||||
if n.NetMap == nil {
|
||||
log.Printf("netmap is not available, unable to determine backend addresses for %s", svc.TailnetTarget.FQDN)
|
||||
return addrs, nil
|
||||
}
|
||||
var (
|
||||
node tailcfg.NodeView
|
||||
nodeFound bool
|
||||
)
|
||||
for _, nn := range n.NetMap.Peers {
|
||||
if equalFQDNs(nn.Name(), svc.TailnetTarget.FQDN) {
|
||||
node = nn
|
||||
nodeFound = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if nodeFound {
|
||||
for _, addr := range node.Addresses().AsSlice() {
|
||||
if addr.Addr().Is6() && !ep.nfr.HasIPV6NAT() {
|
||||
log.Printf("tailnet target %v is an IPv6 address, but this host does not support IPv6 in the chosen firewall mode, skipping.", addr.Addr().String())
|
||||
continue
|
||||
}
|
||||
addrs = append(addrs, addr.Addr())
|
||||
}
|
||||
// Egress target endpoints configured via FQDN are stored, so
|
||||
// that we can determine if a netmap update should trigger a
|
||||
// resync.
|
||||
mak.Set(&ep.targetFQDNs, svc.TailnetTarget.FQDN, node.Addresses().AsSlice())
|
||||
}
|
||||
return addrs, nil
|
||||
}
|
||||
|
||||
// shouldResync parses netmap update and returns true if the update contains
|
||||
// changes for which the egress proxy's firewall should be reconfigured.
|
||||
func (ep *egressProxy) shouldResync(n ipn.Notify) bool {
|
||||
if n.NetMap == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
// If proxy's tailnet addresses have changed, resync.
|
||||
if !reflect.DeepEqual(n.NetMap.SelfNode.Addresses().AsSlice(), ep.tailnetAddrs) {
|
||||
log.Printf("node addresses have changed, trigger egress config resync")
|
||||
ep.tailnetAddrs = n.NetMap.SelfNode.Addresses().AsSlice()
|
||||
return true
|
||||
}
|
||||
|
||||
// If the IPs for any of the egress services configured via FQDN have
|
||||
// changed, resync.
|
||||
for fqdn, ips := range ep.targetFQDNs {
|
||||
for _, nn := range n.NetMap.Peers {
|
||||
if equalFQDNs(nn.Name(), fqdn) {
|
||||
if !reflect.DeepEqual(ips, nn.Addresses().AsSlice()) {
|
||||
log.Printf("backend addresses for egress target %q have changed old IPs %v, new IPs %v trigger egress config resync", nn.Name(), ips, nn.Addresses().AsSlice())
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ensureServiceDeleted ensures that any rules for an egress service are removed
|
||||
// from the firewall configuration.
|
||||
func ensureServiceDeleted(svcName string, svc *egressservices.ServiceStatus, nfr linuxfw.NetfilterRunner) error {
|
||||
|
||||
// Note that the portmap is needed for iptables based firewall only.
|
||||
// Nftables group rules for a service in a chain, so there is no need to
|
||||
// specify individual portmapping based rules.
|
||||
pms := make([]linuxfw.PortMap, 0)
|
||||
for pm := range svc.Ports {
|
||||
pms = append(pms, linuxfw.PortMap{MatchPort: pm.MatchPort, TargetPort: pm.TargetPort, Protocol: pm.Protocol})
|
||||
}
|
||||
|
||||
if err := nfr.DeleteSvc(svcName, tailscaleTunInterface, svc.TailnetTargetIPs, pms); err != nil {
|
||||
return fmt.Errorf("error deleting service %s: %w", svcName, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ensureRulesAdded ensures that all portmapping rules are added to the firewall
|
||||
// configuration. For any rules that already exist, calling this function is a
|
||||
// no-op. In case of nftables, a service consists of one or two (one per IP
|
||||
// family) chains that conain the portmapping rules for the service and the
|
||||
// chains as needed when this function is called.
|
||||
func ensureRulesAdded(rulesPerSvc map[string][]rule, nfr linuxfw.NetfilterRunner) error {
|
||||
for svc, rules := range rulesPerSvc {
|
||||
for _, rule := range rules {
|
||||
log.Printf("ensureRulesAdded svc %s tailnetTarget %s container port %d tailnet port %d protocol %s", svc, rule.tailnetIP, rule.containerPort, rule.tailnetPort, rule.protocol)
|
||||
if err := nfr.EnsurePortMapRuleForSvc(svc, tailscaleTunInterface, rule.tailnetIP, linuxfw.PortMap{MatchPort: rule.containerPort, TargetPort: rule.tailnetPort, Protocol: rule.protocol}); err != nil {
|
||||
return fmt.Errorf("error ensuring rule: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ensureRulesDeleted ensures that the given rules are deleted from the firewall
|
||||
// configuration. For any rules that do not exist, calling this funcion is a
|
||||
// no-op.
|
||||
func ensureRulesDeleted(rulesPerSvc map[string][]rule, nfr linuxfw.NetfilterRunner) error {
|
||||
for svc, rules := range rulesPerSvc {
|
||||
for _, rule := range rules {
|
||||
log.Printf("ensureRulesDeleted svc %s tailnetTarget %s container port %d tailnet port %d protocol %s", svc, rule.tailnetIP, rule.containerPort, rule.tailnetPort, rule.protocol)
|
||||
if err := nfr.DeletePortMapRuleForSvc(svc, tailscaleTunInterface, rule.tailnetIP, linuxfw.PortMap{MatchPort: rule.containerPort, TargetPort: rule.tailnetPort, Protocol: rule.protocol}); err != nil {
|
||||
return fmt.Errorf("error deleting rule: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func lookupCurrentConfig(svcName string, status *egressservices.Status) (*egressservices.ServiceStatus, bool) {
|
||||
if status == nil || len(status.Services) == 0 {
|
||||
return nil, false
|
||||
}
|
||||
c, ok := status.Services[svcName]
|
||||
return c, ok
|
||||
}
|
||||
|
||||
func equalFQDNs(s, s1 string) bool {
|
||||
s, _ = strings.CutSuffix(s, ".")
|
||||
s1, _ = strings.CutSuffix(s1, ".")
|
||||
return strings.EqualFold(s, s1)
|
||||
}
|
||||
|
||||
// rule contains configuration for an egress proxy firewall rule.
|
||||
type rule struct {
|
||||
containerPort uint16 // port to match incoming traffic
|
||||
tailnetPort uint16 // tailnet service port
|
||||
tailnetIP netip.Addr // tailnet service IP
|
||||
protocol string
|
||||
}
|
||||
|
||||
func wantsServicesConfigured(cfgs *egressservices.Configs) bool {
|
||||
return cfgs != nil && len(*cfgs) != 0
|
||||
}
|
||||
|
||||
func hasServicesConfigured(status *egressservices.Status) bool {
|
||||
return status != nil && len(status.Services) != 0
|
||||
}
|
||||
|
||||
func servicesStatusIsEqual(st, st1 *egressservices.Status) bool {
|
||||
if st == nil && st1 == nil {
|
||||
return true
|
||||
}
|
||||
if st == nil || st1 == nil {
|
||||
return false
|
||||
}
|
||||
st.PodIPv4 = ""
|
||||
st1.PodIPv4 = ""
|
||||
return reflect.DeepEqual(*st, *st1)
|
||||
}
|
||||
175
cmd/containerboot/services_test.go
Normal file
175
cmd/containerboot/services_test.go
Normal file
@@ -0,0 +1,175 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
//go:build linux
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"net/netip"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"tailscale.com/kube/egressservices"
|
||||
)
|
||||
|
||||
func Test_updatesForSvc(t *testing.T) {
|
||||
tailnetIPv4, tailnetIPv6 := netip.MustParseAddr("100.99.99.99"), netip.MustParseAddr("fd7a:115c:a1e0::701:b62a")
|
||||
tailnetIPv4_1, tailnetIPv6_1 := netip.MustParseAddr("100.88.88.88"), netip.MustParseAddr("fd7a:115c:a1e0::4101:512f")
|
||||
ports := map[egressservices.PortMap]struct{}{{Protocol: "tcp", MatchPort: 4003, TargetPort: 80}: {}}
|
||||
ports1 := map[egressservices.PortMap]struct{}{{Protocol: "udp", MatchPort: 4004, TargetPort: 53}: {}}
|
||||
ports2 := map[egressservices.PortMap]struct{}{{Protocol: "tcp", MatchPort: 4003, TargetPort: 80}: {},
|
||||
{Protocol: "tcp", MatchPort: 4005, TargetPort: 443}: {}}
|
||||
fqdnSpec := egressservices.Config{
|
||||
TailnetTarget: egressservices.TailnetTarget{FQDN: "test"},
|
||||
Ports: ports,
|
||||
}
|
||||
fqdnSpec1 := egressservices.Config{
|
||||
TailnetTarget: egressservices.TailnetTarget{FQDN: "test"},
|
||||
Ports: ports1,
|
||||
}
|
||||
fqdnSpec2 := egressservices.Config{
|
||||
TailnetTarget: egressservices.TailnetTarget{IP: tailnetIPv4.String()},
|
||||
Ports: ports,
|
||||
}
|
||||
fqdnSpec3 := egressservices.Config{
|
||||
TailnetTarget: egressservices.TailnetTarget{IP: tailnetIPv4.String()},
|
||||
Ports: ports2,
|
||||
}
|
||||
r := rule{containerPort: 4003, tailnetPort: 80, protocol: "tcp", tailnetIP: tailnetIPv4}
|
||||
r1 := rule{containerPort: 4003, tailnetPort: 80, protocol: "tcp", tailnetIP: tailnetIPv6}
|
||||
r2 := rule{tailnetPort: 53, containerPort: 4004, protocol: "udp", tailnetIP: tailnetIPv4}
|
||||
r3 := rule{tailnetPort: 53, containerPort: 4004, protocol: "udp", tailnetIP: tailnetIPv6}
|
||||
r4 := rule{containerPort: 4003, tailnetPort: 80, protocol: "tcp", tailnetIP: tailnetIPv4_1}
|
||||
r5 := rule{containerPort: 4003, tailnetPort: 80, protocol: "tcp", tailnetIP: tailnetIPv6_1}
|
||||
r6 := rule{containerPort: 4005, tailnetPort: 443, protocol: "tcp", tailnetIP: tailnetIPv4}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
svcName string
|
||||
tailnetTargetIPs []netip.Addr
|
||||
podIP string
|
||||
spec egressservices.Config
|
||||
status *egressservices.Status
|
||||
wantRulesToAdd []rule
|
||||
wantRulesToDelete []rule
|
||||
}{
|
||||
{
|
||||
name: "add_fqdn_svc_that_does_not_yet_exist",
|
||||
svcName: "test",
|
||||
tailnetTargetIPs: []netip.Addr{tailnetIPv4, tailnetIPv6},
|
||||
spec: fqdnSpec,
|
||||
status: &egressservices.Status{},
|
||||
wantRulesToAdd: []rule{r, r1},
|
||||
wantRulesToDelete: []rule{},
|
||||
},
|
||||
{
|
||||
name: "fqdn_svc_already_exists",
|
||||
svcName: "test",
|
||||
tailnetTargetIPs: []netip.Addr{tailnetIPv4, tailnetIPv6},
|
||||
spec: fqdnSpec,
|
||||
status: &egressservices.Status{
|
||||
Services: map[string]*egressservices.ServiceStatus{"test": {
|
||||
TailnetTargetIPs: []netip.Addr{tailnetIPv4, tailnetIPv6},
|
||||
TailnetTarget: egressservices.TailnetTarget{FQDN: "test"},
|
||||
Ports: ports,
|
||||
}}},
|
||||
wantRulesToAdd: []rule{},
|
||||
wantRulesToDelete: []rule{},
|
||||
},
|
||||
{
|
||||
name: "fqdn_svc_already_exists_add_port_remove_port",
|
||||
svcName: "test",
|
||||
tailnetTargetIPs: []netip.Addr{tailnetIPv4, tailnetIPv6},
|
||||
spec: fqdnSpec1,
|
||||
status: &egressservices.Status{
|
||||
Services: map[string]*egressservices.ServiceStatus{"test": {
|
||||
TailnetTargetIPs: []netip.Addr{tailnetIPv4, tailnetIPv6},
|
||||
TailnetTarget: egressservices.TailnetTarget{FQDN: "test"},
|
||||
Ports: ports,
|
||||
}}},
|
||||
wantRulesToAdd: []rule{r2, r3},
|
||||
wantRulesToDelete: []rule{r, r1},
|
||||
},
|
||||
{
|
||||
name: "fqdn_svc_already_exists_change_fqdn_backend_ips",
|
||||
svcName: "test",
|
||||
tailnetTargetIPs: []netip.Addr{tailnetIPv4_1, tailnetIPv6_1},
|
||||
spec: fqdnSpec,
|
||||
status: &egressservices.Status{
|
||||
Services: map[string]*egressservices.ServiceStatus{"test": {
|
||||
TailnetTargetIPs: []netip.Addr{tailnetIPv4, tailnetIPv6},
|
||||
TailnetTarget: egressservices.TailnetTarget{FQDN: "test"},
|
||||
Ports: ports,
|
||||
}}},
|
||||
wantRulesToAdd: []rule{r4, r5},
|
||||
wantRulesToDelete: []rule{r, r1},
|
||||
},
|
||||
{
|
||||
name: "add_ip_service",
|
||||
svcName: "test",
|
||||
tailnetTargetIPs: []netip.Addr{tailnetIPv4},
|
||||
spec: fqdnSpec2,
|
||||
status: &egressservices.Status{},
|
||||
wantRulesToAdd: []rule{r},
|
||||
wantRulesToDelete: []rule{},
|
||||
},
|
||||
{
|
||||
name: "add_ip_service_already_exists",
|
||||
svcName: "test",
|
||||
tailnetTargetIPs: []netip.Addr{tailnetIPv4},
|
||||
spec: fqdnSpec2,
|
||||
status: &egressservices.Status{
|
||||
Services: map[string]*egressservices.ServiceStatus{"test": {
|
||||
TailnetTargetIPs: []netip.Addr{tailnetIPv4},
|
||||
TailnetTarget: egressservices.TailnetTarget{IP: tailnetIPv4.String()},
|
||||
Ports: ports,
|
||||
}}},
|
||||
wantRulesToAdd: []rule{},
|
||||
wantRulesToDelete: []rule{},
|
||||
},
|
||||
{
|
||||
name: "ip_service_add_port",
|
||||
svcName: "test",
|
||||
tailnetTargetIPs: []netip.Addr{tailnetIPv4},
|
||||
spec: fqdnSpec3,
|
||||
status: &egressservices.Status{
|
||||
Services: map[string]*egressservices.ServiceStatus{"test": {
|
||||
TailnetTargetIPs: []netip.Addr{tailnetIPv4},
|
||||
TailnetTarget: egressservices.TailnetTarget{IP: tailnetIPv4.String()},
|
||||
Ports: ports,
|
||||
}}},
|
||||
wantRulesToAdd: []rule{r6},
|
||||
wantRulesToDelete: []rule{},
|
||||
},
|
||||
{
|
||||
name: "ip_service_delete_port",
|
||||
svcName: "test",
|
||||
tailnetTargetIPs: []netip.Addr{tailnetIPv4},
|
||||
spec: fqdnSpec,
|
||||
status: &egressservices.Status{
|
||||
Services: map[string]*egressservices.ServiceStatus{"test": {
|
||||
TailnetTargetIPs: []netip.Addr{tailnetIPv4},
|
||||
TailnetTarget: egressservices.TailnetTarget{IP: tailnetIPv4.String()},
|
||||
Ports: ports2,
|
||||
}}},
|
||||
wantRulesToAdd: []rule{},
|
||||
wantRulesToDelete: []rule{r6},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
gotRulesToAdd, gotRulesToDelete, err := updatesForCfg(tt.svcName, tt.spec, tt.status, tt.tailnetTargetIPs)
|
||||
if err != nil {
|
||||
t.Errorf("updatesForSvc() unexpected error %v", err)
|
||||
return
|
||||
}
|
||||
if !reflect.DeepEqual(gotRulesToAdd, tt.wantRulesToAdd) {
|
||||
t.Errorf("updatesForSvc() got rulesToAdd = \n%v\n want rulesToAdd \n%v", gotRulesToAdd, tt.wantRulesToAdd)
|
||||
}
|
||||
if !reflect.DeepEqual(gotRulesToDelete, tt.wantRulesToDelete) {
|
||||
t.Errorf("updatesForSvc() got rulesToDelete = \n%v\n want rulesToDelete \n%v", gotRulesToDelete, tt.wantRulesToDelete)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
324
cmd/containerboot/settings.go
Normal file
324
cmd/containerboot/settings.go
Normal file
@@ -0,0 +1,324 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
//go:build linux
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/netip"
|
||||
"os"
|
||||
"path"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"tailscale.com/ipn/conffile"
|
||||
"tailscale.com/kube/kubeclient"
|
||||
)
|
||||
|
||||
// settings is all the configuration for containerboot.
|
||||
type settings struct {
|
||||
AuthKey string
|
||||
Hostname string
|
||||
Routes *string
|
||||
// ProxyTargetIP is the destination IP to which all incoming
|
||||
// Tailscale traffic should be proxied. If empty, no proxying
|
||||
// is done. This is typically a locally reachable IP.
|
||||
ProxyTargetIP string
|
||||
// ProxyTargetDNSName is a DNS name to whose backing IP addresses all
|
||||
// incoming Tailscale traffic should be proxied.
|
||||
ProxyTargetDNSName string
|
||||
// TailnetTargetIP is the destination IP to which all incoming
|
||||
// non-Tailscale traffic should be proxied. This is typically a
|
||||
// Tailscale IP.
|
||||
TailnetTargetIP string
|
||||
// TailnetTargetFQDN is an MagicDNS name to which all incoming
|
||||
// non-Tailscale traffic should be proxied. This must be a full Tailnet
|
||||
// node FQDN.
|
||||
TailnetTargetFQDN string
|
||||
ServeConfigPath string
|
||||
DaemonExtraArgs string
|
||||
ExtraArgs string
|
||||
InKubernetes bool
|
||||
UserspaceMode bool
|
||||
StateDir string
|
||||
AcceptDNS *bool
|
||||
KubeSecret string
|
||||
SOCKSProxyAddr string
|
||||
HTTPProxyAddr string
|
||||
Socket string
|
||||
AuthOnce bool
|
||||
Root string
|
||||
KubernetesCanPatch bool
|
||||
TailscaledConfigFilePath string
|
||||
EnableForwardingOptimizations bool
|
||||
// If set to true and, if this containerboot instance is a Kubernetes
|
||||
// ingress proxy, set up rules to forward incoming cluster traffic to be
|
||||
// forwarded to the ingress target in cluster.
|
||||
AllowProxyingClusterTrafficViaIngress bool
|
||||
// PodIP is the IP of the Pod if running in Kubernetes. This is used
|
||||
// when setting up rules to proxy cluster traffic to cluster ingress
|
||||
// target.
|
||||
// Deprecated: use PodIPv4, PodIPv6 instead to support dual stack clusters
|
||||
PodIP string
|
||||
PodIPv4 string
|
||||
PodIPv6 string
|
||||
HealthCheckAddrPort string
|
||||
EgressSvcsCfgPath string
|
||||
}
|
||||
|
||||
func configFromEnv() (*settings, error) {
|
||||
cfg := &settings{
|
||||
AuthKey: defaultEnvs([]string{"TS_AUTHKEY", "TS_AUTH_KEY"}, ""),
|
||||
Hostname: defaultEnv("TS_HOSTNAME", ""),
|
||||
Routes: defaultEnvStringPointer("TS_ROUTES"),
|
||||
ServeConfigPath: defaultEnv("TS_SERVE_CONFIG", ""),
|
||||
ProxyTargetIP: defaultEnv("TS_DEST_IP", ""),
|
||||
ProxyTargetDNSName: defaultEnv("TS_EXPERIMENTAL_DEST_DNS_NAME", ""),
|
||||
TailnetTargetIP: defaultEnv("TS_TAILNET_TARGET_IP", ""),
|
||||
TailnetTargetFQDN: defaultEnv("TS_TAILNET_TARGET_FQDN", ""),
|
||||
DaemonExtraArgs: defaultEnv("TS_TAILSCALED_EXTRA_ARGS", ""),
|
||||
ExtraArgs: defaultEnv("TS_EXTRA_ARGS", ""),
|
||||
InKubernetes: os.Getenv("KUBERNETES_SERVICE_HOST") != "",
|
||||
UserspaceMode: defaultBool("TS_USERSPACE", true),
|
||||
StateDir: defaultEnv("TS_STATE_DIR", ""),
|
||||
AcceptDNS: defaultEnvBoolPointer("TS_ACCEPT_DNS"),
|
||||
KubeSecret: defaultEnv("TS_KUBE_SECRET", "tailscale"),
|
||||
SOCKSProxyAddr: defaultEnv("TS_SOCKS5_SERVER", ""),
|
||||
HTTPProxyAddr: defaultEnv("TS_OUTBOUND_HTTP_PROXY_LISTEN", ""),
|
||||
Socket: defaultEnv("TS_SOCKET", "/tmp/tailscaled.sock"),
|
||||
AuthOnce: defaultBool("TS_AUTH_ONCE", false),
|
||||
Root: defaultEnv("TS_TEST_ONLY_ROOT", "/"),
|
||||
TailscaledConfigFilePath: tailscaledConfigFilePath(),
|
||||
AllowProxyingClusterTrafficViaIngress: defaultBool("EXPERIMENTAL_ALLOW_PROXYING_CLUSTER_TRAFFIC_VIA_INGRESS", false),
|
||||
PodIP: defaultEnv("POD_IP", ""),
|
||||
EnableForwardingOptimizations: defaultBool("TS_EXPERIMENTAL_ENABLE_FORWARDING_OPTIMIZATIONS", false),
|
||||
HealthCheckAddrPort: defaultEnv("TS_HEALTHCHECK_ADDR_PORT", ""),
|
||||
EgressSvcsCfgPath: defaultEnv("TS_EGRESS_SERVICES_CONFIG_PATH", ""),
|
||||
}
|
||||
podIPs, ok := os.LookupEnv("POD_IPS")
|
||||
if ok {
|
||||
ips := strings.Split(podIPs, ",")
|
||||
if len(ips) > 2 {
|
||||
return nil, fmt.Errorf("POD_IPs can contain at most 2 IPs, got %d (%v)", len(ips), ips)
|
||||
}
|
||||
for _, ip := range ips {
|
||||
parsed, err := netip.ParseAddr(ip)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error parsing IP address %s: %w", ip, err)
|
||||
}
|
||||
if parsed.Is4() {
|
||||
cfg.PodIPv4 = parsed.String()
|
||||
continue
|
||||
}
|
||||
cfg.PodIPv6 = parsed.String()
|
||||
}
|
||||
}
|
||||
if err := cfg.validate(); err != nil {
|
||||
return nil, fmt.Errorf("invalid configuration: %v", err)
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func (s *settings) validate() error {
|
||||
if s.TailscaledConfigFilePath != "" {
|
||||
dir, file := path.Split(s.TailscaledConfigFilePath)
|
||||
if _, err := os.Stat(dir); err != nil {
|
||||
return fmt.Errorf("error validating whether directory with tailscaled config file %s exists: %w", dir, err)
|
||||
}
|
||||
if _, err := os.Stat(s.TailscaledConfigFilePath); err != nil {
|
||||
return fmt.Errorf("error validating whether tailscaled config directory %q contains tailscaled config for current capability version %q: %w. If this is a Tailscale Kubernetes operator proxy, please ensure that the version of the operator is not older than the version of the proxy", dir, file, err)
|
||||
}
|
||||
if _, err := conffile.Load(s.TailscaledConfigFilePath); err != nil {
|
||||
return fmt.Errorf("error validating tailscaled configfile contents: %w", err)
|
||||
}
|
||||
}
|
||||
if s.ProxyTargetIP != "" && s.UserspaceMode {
|
||||
return errors.New("TS_DEST_IP is not supported with TS_USERSPACE")
|
||||
}
|
||||
if s.ProxyTargetDNSName != "" && s.UserspaceMode {
|
||||
return errors.New("TS_EXPERIMENTAL_DEST_DNS_NAME is not supported with TS_USERSPACE")
|
||||
}
|
||||
if s.ProxyTargetDNSName != "" && s.ProxyTargetIP != "" {
|
||||
return errors.New("TS_EXPERIMENTAL_DEST_DNS_NAME and TS_DEST_IP cannot both be set")
|
||||
}
|
||||
if s.TailnetTargetIP != "" && s.UserspaceMode {
|
||||
return errors.New("TS_TAILNET_TARGET_IP is not supported with TS_USERSPACE")
|
||||
}
|
||||
if s.TailnetTargetFQDN != "" && s.UserspaceMode {
|
||||
return errors.New("TS_TAILNET_TARGET_FQDN is not supported with TS_USERSPACE")
|
||||
}
|
||||
if s.TailnetTargetFQDN != "" && s.TailnetTargetIP != "" {
|
||||
return errors.New("Both TS_TAILNET_TARGET_IP and TS_TAILNET_FQDN cannot be set")
|
||||
}
|
||||
if s.TailscaledConfigFilePath != "" && (s.AcceptDNS != nil || s.AuthKey != "" || s.Routes != nil || s.ExtraArgs != "" || s.Hostname != "") {
|
||||
return errors.New("TS_EXPERIMENTAL_VERSIONED_CONFIG_DIR cannot be set in combination with TS_HOSTNAME, TS_EXTRA_ARGS, TS_AUTHKEY, TS_ROUTES, TS_ACCEPT_DNS.")
|
||||
}
|
||||
if s.AllowProxyingClusterTrafficViaIngress && s.UserspaceMode {
|
||||
return errors.New("EXPERIMENTAL_ALLOW_PROXYING_CLUSTER_TRAFFIC_VIA_INGRESS is not supported in userspace mode")
|
||||
}
|
||||
if s.AllowProxyingClusterTrafficViaIngress && s.ServeConfigPath == "" {
|
||||
return errors.New("EXPERIMENTAL_ALLOW_PROXYING_CLUSTER_TRAFFIC_VIA_INGRESS is set but this is not a cluster ingress proxy")
|
||||
}
|
||||
if s.AllowProxyingClusterTrafficViaIngress && s.PodIP == "" {
|
||||
return errors.New("EXPERIMENTAL_ALLOW_PROXYING_CLUSTER_TRAFFIC_VIA_INGRESS is set but POD_IP is not set")
|
||||
}
|
||||
if s.EnableForwardingOptimizations && s.UserspaceMode {
|
||||
return errors.New("TS_EXPERIMENTAL_ENABLE_FORWARDING_OPTIMIZATIONS is not supported in userspace mode")
|
||||
}
|
||||
if s.HealthCheckAddrPort != "" {
|
||||
if _, err := netip.ParseAddrPort(s.HealthCheckAddrPort); err != nil {
|
||||
return fmt.Errorf("error parsing TS_HEALTH_CHECK_ADDR_PORT value %q: %w", s.HealthCheckAddrPort, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// setupKube is responsible for doing any necessary configuration and checks to
|
||||
// ensure that tailscale state storage and authentication mechanism will work on
|
||||
// Kubernetes.
|
||||
func (cfg *settings) setupKube(ctx context.Context) error {
|
||||
if cfg.KubeSecret == "" {
|
||||
return nil
|
||||
}
|
||||
canPatch, canCreate, err := kc.CheckSecretPermissions(ctx, cfg.KubeSecret)
|
||||
if err != nil {
|
||||
return fmt.Errorf("some Kubernetes permissions are missing, please check your RBAC configuration: %v", err)
|
||||
}
|
||||
cfg.KubernetesCanPatch = canPatch
|
||||
|
||||
s, err := kc.GetSecret(ctx, cfg.KubeSecret)
|
||||
if err != nil {
|
||||
if !kubeclient.IsNotFoundErr(err) {
|
||||
return fmt.Errorf("getting Tailscale state Secret %s: %v", cfg.KubeSecret, err)
|
||||
}
|
||||
|
||||
if !canCreate {
|
||||
return fmt.Errorf("tailscale state Secret %s does not exist and we don't have permissions to create it. "+
|
||||
"If you intend to store tailscale state elsewhere than a Kubernetes Secret, "+
|
||||
"you can explicitly set TS_KUBE_SECRET env var to an empty string. "+
|
||||
"Else ensure that RBAC is set up that allows the service account associated with this installation to create Secrets.", cfg.KubeSecret)
|
||||
}
|
||||
}
|
||||
|
||||
// Return early if we already have an auth key.
|
||||
if cfg.AuthKey != "" || isOneStepConfig(cfg) {
|
||||
return nil
|
||||
}
|
||||
|
||||
if s == nil {
|
||||
log.Print("TS_AUTHKEY not provided and state Secret does not exist, login will be interactive if needed.")
|
||||
return nil
|
||||
}
|
||||
|
||||
keyBytes, _ := s.Data["authkey"]
|
||||
key := string(keyBytes)
|
||||
|
||||
if key != "" {
|
||||
// Enforce that we must be able to patch out the authkey after
|
||||
// authenticating if you want to use this feature. This avoids
|
||||
// us having to deal with the case where we might leave behind
|
||||
// an unnecessary reusable authkey in a secret, like a rake in
|
||||
// the grass.
|
||||
if !cfg.KubernetesCanPatch {
|
||||
return errors.New("authkey found in TS_KUBE_SECRET, but the pod doesn't have patch permissions on the Secret to manage the authkey.")
|
||||
}
|
||||
cfg.AuthKey = key
|
||||
}
|
||||
|
||||
log.Print("No authkey found in state Secret and TS_AUTHKEY not provided, login will be interactive if needed.")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// isTwoStepConfigAuthOnce returns true if the Tailscale node should be configured
|
||||
// in two steps and login should only happen once.
|
||||
// Step 1: run 'tailscaled'
|
||||
// Step 2):
|
||||
// A) if this is the first time starting this node run 'tailscale up --authkey <authkey> <config opts>'
|
||||
// B) if this is not the first time starting this node run 'tailscale set <config opts>'.
|
||||
func isTwoStepConfigAuthOnce(cfg *settings) bool {
|
||||
return cfg.AuthOnce && cfg.TailscaledConfigFilePath == ""
|
||||
}
|
||||
|
||||
// isTwoStepConfigAlwaysAuth returns true if the Tailscale node should be configured
|
||||
// in two steps and we should log in every time it starts.
|
||||
// Step 1: run 'tailscaled'
|
||||
// Step 2): run 'tailscale up --authkey <authkey> <config opts>'
|
||||
func isTwoStepConfigAlwaysAuth(cfg *settings) bool {
|
||||
return !cfg.AuthOnce && cfg.TailscaledConfigFilePath == ""
|
||||
}
|
||||
|
||||
// isOneStepConfig returns true if the Tailscale node should always be ran and
|
||||
// configured in a single step by running 'tailscaled <config opts>'
|
||||
func isOneStepConfig(cfg *settings) bool {
|
||||
return cfg.TailscaledConfigFilePath != ""
|
||||
}
|
||||
|
||||
// isL3Proxy returns true if the Tailscale node needs to be configured to act
|
||||
// as an L3 proxy, proxying to an endpoint provided via one of the config env
|
||||
// vars.
|
||||
func isL3Proxy(cfg *settings) bool {
|
||||
return cfg.ProxyTargetIP != "" || cfg.ProxyTargetDNSName != "" || cfg.TailnetTargetIP != "" || cfg.TailnetTargetFQDN != "" || cfg.AllowProxyingClusterTrafficViaIngress || cfg.EgressSvcsCfgPath != ""
|
||||
}
|
||||
|
||||
// hasKubeStateStore returns true if the state must be stored in a Kubernetes
|
||||
// Secret.
|
||||
func hasKubeStateStore(cfg *settings) bool {
|
||||
return cfg.InKubernetes && cfg.KubernetesCanPatch && cfg.KubeSecret != ""
|
||||
}
|
||||
|
||||
// defaultEnv returns the value of the given envvar name, or defVal if
|
||||
// unset.
|
||||
func defaultEnv(name, defVal string) string {
|
||||
if v, ok := os.LookupEnv(name); ok {
|
||||
return v
|
||||
}
|
||||
return defVal
|
||||
}
|
||||
|
||||
// defaultEnvStringPointer returns a pointer to the given envvar value if set, else
|
||||
// returns nil. This is useful in cases where we need to distinguish between a
|
||||
// variable being set to empty string vs unset.
|
||||
func defaultEnvStringPointer(name string) *string {
|
||||
if v, ok := os.LookupEnv(name); ok {
|
||||
return &v
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// defaultEnvBoolPointer returns a pointer to the given envvar value if set, else
|
||||
// returns nil. This is useful in cases where we need to distinguish between a
|
||||
// variable being explicitly set to false vs unset.
|
||||
func defaultEnvBoolPointer(name string) *bool {
|
||||
v := os.Getenv(name)
|
||||
ret, err := strconv.ParseBool(v)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return &ret
|
||||
}
|
||||
|
||||
func defaultEnvs(names []string, defVal string) string {
|
||||
for _, name := range names {
|
||||
if v, ok := os.LookupEnv(name); ok {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return defVal
|
||||
}
|
||||
|
||||
// defaultBool returns the boolean value of the given envvar name, or
|
||||
// defVal if unset or not a bool.
|
||||
func defaultBool(name string, defVal bool) bool {
|
||||
v := os.Getenv(name)
|
||||
ret, err := strconv.ParseBool(v)
|
||||
if err != nil {
|
||||
return defVal
|
||||
}
|
||||
return ret
|
||||
}
|
||||
162
cmd/containerboot/tailscaled.go
Normal file
162
cmd/containerboot/tailscaled.go
Normal file
@@ -0,0 +1,162 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
//go:build linux
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"log"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"tailscale.com/client/tailscale"
|
||||
)
|
||||
|
||||
func startTailscaled(ctx context.Context, cfg *settings) (*tailscale.LocalClient, *os.Process, error) {
|
||||
args := tailscaledArgs(cfg)
|
||||
// tailscaled runs without context, since it needs to persist
|
||||
// beyond the startup timeout in ctx.
|
||||
cmd := exec.Command("tailscaled", args...)
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{
|
||||
Setpgid: true,
|
||||
}
|
||||
log.Printf("Starting tailscaled")
|
||||
if err := cmd.Start(); err != nil {
|
||||
return nil, nil, fmt.Errorf("starting tailscaled failed: %v", err)
|
||||
}
|
||||
|
||||
// Wait for the socket file to appear, otherwise API ops will racily fail.
|
||||
log.Printf("Waiting for tailscaled socket")
|
||||
for {
|
||||
if ctx.Err() != nil {
|
||||
log.Fatalf("Timed out waiting for tailscaled socket")
|
||||
}
|
||||
_, err := os.Stat(cfg.Socket)
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
continue
|
||||
} else if err != nil {
|
||||
log.Fatalf("Waiting for tailscaled socket: %v", err)
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
tsClient := &tailscale.LocalClient{
|
||||
Socket: cfg.Socket,
|
||||
UseSocketOnly: true,
|
||||
}
|
||||
|
||||
return tsClient, cmd.Process, nil
|
||||
}
|
||||
|
||||
// tailscaledArgs uses cfg to construct the argv for tailscaled.
|
||||
func tailscaledArgs(cfg *settings) []string {
|
||||
args := []string{"--socket=" + cfg.Socket}
|
||||
switch {
|
||||
case cfg.InKubernetes && cfg.KubeSecret != "":
|
||||
args = append(args, "--state=kube:"+cfg.KubeSecret)
|
||||
if cfg.StateDir == "" {
|
||||
cfg.StateDir = "/tmp"
|
||||
}
|
||||
fallthrough
|
||||
case cfg.StateDir != "":
|
||||
args = append(args, "--statedir="+cfg.StateDir)
|
||||
default:
|
||||
args = append(args, "--state=mem:", "--statedir=/tmp")
|
||||
}
|
||||
|
||||
if cfg.UserspaceMode {
|
||||
args = append(args, "--tun=userspace-networking")
|
||||
} else if err := ensureTunFile(cfg.Root); err != nil {
|
||||
log.Fatalf("ensuring that /dev/net/tun exists: %v", err)
|
||||
}
|
||||
|
||||
if cfg.SOCKSProxyAddr != "" {
|
||||
args = append(args, "--socks5-server="+cfg.SOCKSProxyAddr)
|
||||
}
|
||||
if cfg.HTTPProxyAddr != "" {
|
||||
args = append(args, "--outbound-http-proxy-listen="+cfg.HTTPProxyAddr)
|
||||
}
|
||||
if cfg.TailscaledConfigFilePath != "" {
|
||||
args = append(args, "--config="+cfg.TailscaledConfigFilePath)
|
||||
}
|
||||
if cfg.DaemonExtraArgs != "" {
|
||||
args = append(args, strings.Fields(cfg.DaemonExtraArgs)...)
|
||||
}
|
||||
return args
|
||||
}
|
||||
|
||||
// tailscaleUp uses cfg to run 'tailscale up' everytime containerboot starts, or
|
||||
// if TS_AUTH_ONCE is set, only the first time containerboot starts.
|
||||
func tailscaleUp(ctx context.Context, cfg *settings) error {
|
||||
args := []string{"--socket=" + cfg.Socket, "up"}
|
||||
if cfg.AcceptDNS != nil && *cfg.AcceptDNS {
|
||||
args = append(args, "--accept-dns=true")
|
||||
} else {
|
||||
args = append(args, "--accept-dns=false")
|
||||
}
|
||||
if cfg.AuthKey != "" {
|
||||
args = append(args, "--authkey="+cfg.AuthKey)
|
||||
}
|
||||
// --advertise-routes can be passed an empty string to configure a
|
||||
// device (that might have previously advertised subnet routes) to not
|
||||
// advertise any routes. Respect an empty string passed by a user and
|
||||
// use it to explicitly unset the routes.
|
||||
if cfg.Routes != nil {
|
||||
args = append(args, "--advertise-routes="+*cfg.Routes)
|
||||
}
|
||||
if cfg.Hostname != "" {
|
||||
args = append(args, "--hostname="+cfg.Hostname)
|
||||
}
|
||||
if cfg.ExtraArgs != "" {
|
||||
args = append(args, strings.Fields(cfg.ExtraArgs)...)
|
||||
}
|
||||
log.Printf("Running 'tailscale up'")
|
||||
cmd := exec.CommandContext(ctx, "tailscale", args...)
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
if err := cmd.Run(); err != nil {
|
||||
return fmt.Errorf("tailscale up failed: %v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// tailscaleSet uses cfg to run 'tailscale set' to set any known configuration
|
||||
// options that are passed in via environment variables. This is run after the
|
||||
// node is in Running state and only if TS_AUTH_ONCE is set.
|
||||
func tailscaleSet(ctx context.Context, cfg *settings) error {
|
||||
args := []string{"--socket=" + cfg.Socket, "set"}
|
||||
if cfg.AcceptDNS != nil && *cfg.AcceptDNS {
|
||||
args = append(args, "--accept-dns=true")
|
||||
} else {
|
||||
args = append(args, "--accept-dns=false")
|
||||
}
|
||||
// --advertise-routes can be passed an empty string to configure a
|
||||
// device (that might have previously advertised subnet routes) to not
|
||||
// advertise any routes. Respect an empty string passed by a user and
|
||||
// use it to explicitly unset the routes.
|
||||
if cfg.Routes != nil {
|
||||
args = append(args, "--advertise-routes="+*cfg.Routes)
|
||||
}
|
||||
if cfg.Hostname != "" {
|
||||
args = append(args, "--hostname="+cfg.Hostname)
|
||||
}
|
||||
log.Printf("Running 'tailscale set'")
|
||||
cmd := exec.CommandContext(ctx, "tailscale", args...)
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
if err := cmd.Run(); err != nil {
|
||||
return fmt.Errorf("tailscale set failed: %v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
109
cmd/derper/README.md
Normal file
109
cmd/derper/README.md
Normal file
@@ -0,0 +1,109 @@
|
||||
# DERP
|
||||
|
||||
This is the code for the [Tailscale DERP server](https://tailscale.com/kb/1232/derp-servers).
|
||||
|
||||
In general, you should not need to or want to run this code. The overwhelming
|
||||
majority of Tailscale users (both individuals and companies) do not.
|
||||
|
||||
In the happy path, Tailscale establishes direct connections between peers and
|
||||
data plane traffic flows directly between them, without using DERP for more than
|
||||
acting as a low bandwidth side channel to bootstrap the NAT traversal. If you
|
||||
find yourself wanting DERP for more bandwidth, the real problem is usually the
|
||||
network configuration of your Tailscale node(s), making sure that Tailscale can
|
||||
get direction connections via some mechanism.
|
||||
|
||||
If you've decided or been advised to run your own `derper`, then read on.
|
||||
|
||||
## Caveats
|
||||
|
||||
* Node sharing and other cross-Tailnet features don't work when using custom
|
||||
DERP servers.
|
||||
|
||||
* DERP servers only see encrypted WireGuard packets and thus are not useful for
|
||||
network-level debugging.
|
||||
|
||||
* The Tailscale control plane does certain geo-level steering features and
|
||||
optimizations that are not available when using custom DERP servers.
|
||||
|
||||
## Guide to running `cmd/derper`
|
||||
|
||||
* You must build and update the `cmd/derper` binary yourself. There are no
|
||||
packages. Use `go install tailscale.com/cmd/derper@latest` with the latest
|
||||
version of Go. You should update this binary approximately as regularly as
|
||||
you update Tailscale nodes. If using `--verify-clients`, the `derper` binary
|
||||
and `tailscaled` binary on the machine must be built from the same git revision.
|
||||
(It might work otherwise, but they're developed and only tested together.)
|
||||
|
||||
* The DERP protocol does a protocol switch inside TLS from HTTP to a custom
|
||||
bidirectional binary protocol. It is thus incompatible with many HTTP proxies.
|
||||
Do not put `derper` behind another HTTP proxy.
|
||||
|
||||
* The `tailscaled` client does its own selection of the fastest/nearest DERP
|
||||
server based on latency measurements. Do not put `derper` behind a global load
|
||||
balancer.
|
||||
|
||||
* DERP servers should ideally have both a static IPv4 and static IPv6 address.
|
||||
Both of those should be listed in the DERP map so the client doesn't need to
|
||||
rely on its DNS which might be broken and dependent on DERP to get back up.
|
||||
|
||||
* A DERP server should not share an IP address with any other DERP server.
|
||||
|
||||
* Avoid having multiple DERP nodes in a region. If you must, they all need to be
|
||||
meshed with each other and monitored. Having two one-node "regions" in the
|
||||
same datacenter is usually easier and more reliable than meshing, at the cost
|
||||
of more required connections from clients in some cases. If your clients
|
||||
aren't mobile (battery constrained), one node regions are definitely
|
||||
preferred. If you really need multiple nodes in a region for HA reasons, two
|
||||
is sufficient.
|
||||
|
||||
* Monitor your DERP servers with [`cmd/derpprobe`](../derpprobe/).
|
||||
|
||||
* If using `--verify-clients`, a `tailscaled` must be running alongside the
|
||||
`derper`, and all clients must be visible to the derper tailscaled in the ACL.
|
||||
|
||||
* If using `--verify-clients`, a `tailscaled` must also be running alongside
|
||||
your `derpprobe`, and `derpprobe` needs to use `--derp-map=local`.
|
||||
|
||||
* The firewall on the `derper` should permit TCP ports 80 and 443 and UDP port
|
||||
3478.
|
||||
|
||||
* Only LetsEncrypt certs are rotated automatically. Other cert updates require a
|
||||
restart.
|
||||
|
||||
* Don't use a firewall in front of `derper` that suppresses `RST`s upon
|
||||
receiving traffic to a dead or unknown connection.
|
||||
|
||||
* Don't rate-limit UDP STUN packets.
|
||||
|
||||
* Don't rate-limit outbound TCP traffic (only inbound).
|
||||
|
||||
## Diagnostics
|
||||
|
||||
This is not a complete guide on DERP diagnostics.
|
||||
|
||||
Running your own DERP services requires exeprtise in multi-layer network and
|
||||
application diagnostics. As the DERP runs multiple protocols at multiple layers
|
||||
and is not a regular HTTP(s) server you will need expertise in correlative
|
||||
analysis to diagnose the most tricky problems. There is no "plain text" or
|
||||
"open" mode of operation for DERP.
|
||||
|
||||
* The debug handler is accessible at URL path `/debug/`. It is only accessible
|
||||
over localhost or from a Tailscale IP address.
|
||||
|
||||
* Go pprof can be accessed via the debug handler at `/debug/pprof/`
|
||||
|
||||
* Prometheus compatible metrics can be gathered from the debug handler at
|
||||
`/debug/varz`.
|
||||
|
||||
* `cmd/stunc` in the Tailscale repository provides a basic tool for diagnosing
|
||||
issues with STUN.
|
||||
|
||||
* `cmd/derpprobe` provides a service for monitoring DERP cluster health.
|
||||
|
||||
* `tailscale debug derp` and `tailscale netcheck` provide additional client
|
||||
driven diagnostic information for DERP communications.
|
||||
|
||||
* Tailscale logs may provide insight for certain problems, such as if DERPs are
|
||||
unreachable or peers are regularly not reachable in their DERP home regions.
|
||||
There are many possible misconfiguration causes for these problems, but
|
||||
regular log entries are a good first indicator that there is a problem.
|
||||
@@ -7,9 +7,19 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
|
||||
W 💣 github.com/alexbrainman/sspi/negotiate from tailscale.com/net/tshttpproxy
|
||||
github.com/beorn7/perks/quantile from github.com/prometheus/client_golang/prometheus
|
||||
💣 github.com/cespare/xxhash/v2 from github.com/prometheus/client_golang/prometheus
|
||||
github.com/coder/websocket from tailscale.com/cmd/derper+
|
||||
github.com/coder/websocket/internal/errd from github.com/coder/websocket
|
||||
github.com/coder/websocket/internal/util from github.com/coder/websocket
|
||||
github.com/coder/websocket/internal/xsync from github.com/coder/websocket
|
||||
L github.com/coreos/go-iptables/iptables from tailscale.com/util/linuxfw
|
||||
W 💣 github.com/dblohm7/wingoes from tailscale.com/util/winutil
|
||||
github.com/fxamacker/cbor/v2 from tailscale.com/tka
|
||||
github.com/go-json-experiment/json from tailscale.com/types/opt+
|
||||
github.com/go-json-experiment/json/internal from github.com/go-json-experiment/json+
|
||||
github.com/go-json-experiment/json/internal/jsonflags from github.com/go-json-experiment/json+
|
||||
github.com/go-json-experiment/json/internal/jsonopts from github.com/go-json-experiment/json+
|
||||
github.com/go-json-experiment/json/internal/jsonwire from github.com/go-json-experiment/json+
|
||||
github.com/go-json-experiment/json/jsontext from github.com/go-json-experiment/json+
|
||||
github.com/golang/groupcache/lru from tailscale.com/net/dnscache
|
||||
L github.com/google/nftables from tailscale.com/util/linuxfw
|
||||
L 💣 github.com/google/nftables/alignedbuff from github.com/google/nftables/xt
|
||||
@@ -42,11 +52,11 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
|
||||
W github.com/tailscale/go-winio/internal/stringbuffer from github.com/tailscale/go-winio/internal/fs
|
||||
W github.com/tailscale/go-winio/pkg/guid from github.com/tailscale/go-winio+
|
||||
L 💣 github.com/tailscale/netlink from tailscale.com/util/linuxfw
|
||||
L 💣 github.com/vishvananda/netlink/nl from github.com/tailscale/netlink
|
||||
L 💣 github.com/tailscale/netlink/nl from github.com/tailscale/netlink
|
||||
L github.com/vishvananda/netns from github.com/tailscale/netlink+
|
||||
github.com/x448/float16 from github.com/fxamacker/cbor/v2
|
||||
💣 go4.org/mem from tailscale.com/client/tailscale+
|
||||
go4.org/netipx from tailscale.com/net/tsaddr+
|
||||
go4.org/netipx from tailscale.com/net/tsaddr
|
||||
W 💣 golang.zx2c4.com/wireguard/windows/tunnel/winipcfg from tailscale.com/net/netmon+
|
||||
google.golang.org/protobuf/encoding/protodelim from github.com/prometheus/common/expfmt
|
||||
google.golang.org/protobuf/encoding/prototext from github.com/prometheus/common/expfmt+
|
||||
@@ -76,10 +86,6 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
|
||||
google.golang.org/protobuf/runtime/protoiface from google.golang.org/protobuf/internal/impl+
|
||||
google.golang.org/protobuf/runtime/protoimpl from github.com/prometheus/client_model/go+
|
||||
google.golang.org/protobuf/types/known/timestamppb from github.com/prometheus/client_golang/prometheus+
|
||||
nhooyr.io/websocket from tailscale.com/cmd/derper+
|
||||
nhooyr.io/websocket/internal/errd from nhooyr.io/websocket
|
||||
nhooyr.io/websocket/internal/util from nhooyr.io/websocket
|
||||
nhooyr.io/websocket/internal/xsync from nhooyr.io/websocket
|
||||
tailscale.com from tailscale.com/version
|
||||
tailscale.com/atomicfile from tailscale.com/cmd/derper+
|
||||
tailscale.com/client/tailscale from tailscale.com/derp
|
||||
@@ -93,39 +99,39 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
|
||||
tailscale.com/hostinfo from tailscale.com/net/netmon+
|
||||
tailscale.com/ipn from tailscale.com/client/tailscale
|
||||
tailscale.com/ipn/ipnstate from tailscale.com/client/tailscale+
|
||||
tailscale.com/kube/kubetypes from tailscale.com/envknob
|
||||
tailscale.com/metrics from tailscale.com/cmd/derper+
|
||||
tailscale.com/net/dnscache from tailscale.com/derp/derphttp
|
||||
tailscale.com/net/flowtrack from tailscale.com/net/packet+
|
||||
tailscale.com/net/ktimeout from tailscale.com/cmd/derper
|
||||
tailscale.com/net/netaddr from tailscale.com/ipn+
|
||||
tailscale.com/net/netknob from tailscale.com/net/netns
|
||||
💣 tailscale.com/net/netmon from tailscale.com/derp/derphttp+
|
||||
tailscale.com/net/netns from tailscale.com/derp/derphttp
|
||||
💣 tailscale.com/net/netns from tailscale.com/derp/derphttp
|
||||
tailscale.com/net/netutil from tailscale.com/client/tailscale
|
||||
tailscale.com/net/packet from tailscale.com/wgengine/filter
|
||||
tailscale.com/net/sockstats from tailscale.com/derp/derphttp
|
||||
tailscale.com/net/stun from tailscale.com/net/stunserver
|
||||
tailscale.com/net/stunserver from tailscale.com/cmd/derper
|
||||
L tailscale.com/net/tcpinfo from tailscale.com/derp
|
||||
tailscale.com/net/tlsdial from tailscale.com/derp/derphttp
|
||||
tailscale.com/net/tlsdial/blockblame from tailscale.com/net/tlsdial
|
||||
tailscale.com/net/tsaddr from tailscale.com/ipn+
|
||||
💣 tailscale.com/net/tshttpproxy from tailscale.com/derp/derphttp+
|
||||
tailscale.com/net/wsconn from tailscale.com/cmd/derper+
|
||||
tailscale.com/net/wsconn from tailscale.com/cmd/derper
|
||||
tailscale.com/paths from tailscale.com/client/tailscale
|
||||
💣 tailscale.com/safesocket from tailscale.com/client/tailscale
|
||||
tailscale.com/syncs from tailscale.com/cmd/derper+
|
||||
tailscale.com/tailcfg from tailscale.com/client/tailscale+
|
||||
tailscale.com/tka from tailscale.com/client/tailscale+
|
||||
W tailscale.com/tsconst from tailscale.com/net/netmon
|
||||
W tailscale.com/tsconst from tailscale.com/net/netmon+
|
||||
tailscale.com/tstime from tailscale.com/derp+
|
||||
tailscale.com/tstime/mono from tailscale.com/tstime/rate
|
||||
tailscale.com/tstime/rate from tailscale.com/derp+
|
||||
tailscale.com/tstime/rate from tailscale.com/derp
|
||||
tailscale.com/tsweb from tailscale.com/cmd/derper
|
||||
tailscale.com/tsweb/promvarz from tailscale.com/tsweb
|
||||
tailscale.com/tsweb/varz from tailscale.com/tsweb+
|
||||
tailscale.com/types/dnstype from tailscale.com/tailcfg
|
||||
tailscale.com/types/dnstype from tailscale.com/tailcfg+
|
||||
tailscale.com/types/empty from tailscale.com/ipn
|
||||
tailscale.com/types/ipproto from tailscale.com/net/flowtrack+
|
||||
tailscale.com/types/ipproto from tailscale.com/tailcfg+
|
||||
tailscale.com/types/key from tailscale.com/client/tailscale+
|
||||
tailscale.com/types/lazy from tailscale.com/version+
|
||||
tailscale.com/types/logger from tailscale.com/cmd/derper+
|
||||
@@ -134,6 +140,7 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
|
||||
tailscale.com/types/persist from tailscale.com/ipn
|
||||
tailscale.com/types/preftype from tailscale.com/ipn
|
||||
tailscale.com/types/ptr from tailscale.com/hostinfo+
|
||||
tailscale.com/types/result from tailscale.com/util/lineiter
|
||||
tailscale.com/types/structs from tailscale.com/ipn+
|
||||
tailscale.com/types/tkatype from tailscale.com/client/tailscale+
|
||||
tailscale.com/types/views from tailscale.com/ipn+
|
||||
@@ -142,11 +149,13 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
|
||||
tailscale.com/util/cloudenv from tailscale.com/hostinfo+
|
||||
W tailscale.com/util/cmpver from tailscale.com/net/tshttpproxy
|
||||
tailscale.com/util/ctxkey from tailscale.com/tsweb+
|
||||
💣 tailscale.com/util/deephash from tailscale.com/util/syspolicy/setting
|
||||
L 💣 tailscale.com/util/dirwalk from tailscale.com/metrics
|
||||
tailscale.com/util/dnsname from tailscale.com/hostinfo+
|
||||
tailscale.com/util/fastuuid from tailscale.com/tsweb
|
||||
💣 tailscale.com/util/hashx from tailscale.com/util/deephash
|
||||
tailscale.com/util/httpm from tailscale.com/client/tailscale
|
||||
tailscale.com/util/lineread from tailscale.com/hostinfo+
|
||||
tailscale.com/util/lineiter from tailscale.com/hostinfo+
|
||||
L tailscale.com/util/linuxfw from tailscale.com/net/netns
|
||||
tailscale.com/util/mak from tailscale.com/health+
|
||||
tailscale.com/util/multierr from tailscale.com/health+
|
||||
@@ -155,26 +164,38 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
|
||||
tailscale.com/util/singleflight from tailscale.com/net/dnscache
|
||||
tailscale.com/util/slicesx from tailscale.com/cmd/derper+
|
||||
tailscale.com/util/syspolicy from tailscale.com/ipn
|
||||
tailscale.com/util/syspolicy/internal from tailscale.com/util/syspolicy/setting+
|
||||
tailscale.com/util/syspolicy/internal/loggerx from tailscale.com/util/syspolicy/internal/metrics+
|
||||
tailscale.com/util/syspolicy/internal/metrics from tailscale.com/util/syspolicy/source
|
||||
tailscale.com/util/syspolicy/rsop from tailscale.com/util/syspolicy
|
||||
tailscale.com/util/syspolicy/setting from tailscale.com/util/syspolicy+
|
||||
tailscale.com/util/syspolicy/source from tailscale.com/util/syspolicy+
|
||||
tailscale.com/util/testenv from tailscale.com/util/syspolicy+
|
||||
tailscale.com/util/usermetric from tailscale.com/health
|
||||
tailscale.com/util/vizerror from tailscale.com/tailcfg+
|
||||
W 💣 tailscale.com/util/winutil from tailscale.com/hostinfo+
|
||||
W 💣 tailscale.com/util/winutil/winenv from tailscale.com/hostinfo
|
||||
W 💣 tailscale.com/util/winutil/gp from tailscale.com/util/syspolicy/source
|
||||
W 💣 tailscale.com/util/winutil/winenv from tailscale.com/hostinfo+
|
||||
tailscale.com/version from tailscale.com/derp+
|
||||
tailscale.com/version/distro from tailscale.com/envknob+
|
||||
tailscale.com/wgengine/filter from tailscale.com/types/netmap
|
||||
tailscale.com/wgengine/filter/filtertype from tailscale.com/types/netmap
|
||||
golang.org/x/crypto/acme from golang.org/x/crypto/acme/autocert
|
||||
golang.org/x/crypto/acme/autocert from tailscale.com/cmd/derper
|
||||
golang.org/x/crypto/argon2 from tailscale.com/tka
|
||||
golang.org/x/crypto/blake2b from golang.org/x/crypto/argon2+
|
||||
golang.org/x/crypto/blake2s from tailscale.com/tka
|
||||
golang.org/x/crypto/chacha20 from golang.org/x/crypto/chacha20poly1305
|
||||
golang.org/x/crypto/chacha20poly1305 from crypto/tls
|
||||
golang.org/x/crypto/chacha20poly1305 from crypto/tls+
|
||||
golang.org/x/crypto/cryptobyte from crypto/ecdsa+
|
||||
golang.org/x/crypto/cryptobyte/asn1 from crypto/ecdsa+
|
||||
golang.org/x/crypto/curve25519 from golang.org/x/crypto/nacl/box+
|
||||
golang.org/x/crypto/hkdf from crypto/tls
|
||||
golang.org/x/crypto/hkdf from crypto/tls+
|
||||
golang.org/x/crypto/nacl/box from tailscale.com/types/key
|
||||
golang.org/x/crypto/nacl/secretbox from golang.org/x/crypto/nacl/box
|
||||
golang.org/x/crypto/salsa20/salsa from golang.org/x/crypto/nacl/box+
|
||||
golang.org/x/crypto/sha3 from crypto/internal/mlkem768+
|
||||
W golang.org/x/exp/constraints from tailscale.com/util/winutil
|
||||
golang.org/x/exp/maps from tailscale.com/util/syspolicy/setting+
|
||||
L golang.org/x/net/bpf from github.com/mdlayher/netlink+
|
||||
golang.org/x/net/dns/dnsmessage from net+
|
||||
golang.org/x/net/http/httpguts from net/http
|
||||
@@ -235,7 +256,7 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
|
||||
encoding/pem from crypto/tls+
|
||||
errors from bufio+
|
||||
expvar from github.com/prometheus/client_golang/prometheus+
|
||||
flag from tailscale.com/cmd/derper
|
||||
flag from tailscale.com/cmd/derper+
|
||||
fmt from compress/flate+
|
||||
go/token from google.golang.org/protobuf/internal/strs
|
||||
hash from crypto+
|
||||
@@ -243,9 +264,11 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
|
||||
hash/fnv from google.golang.org/protobuf/internal/detrand
|
||||
hash/maphash from go4.org/mem
|
||||
html from net/http/pprof+
|
||||
html/template from tailscale.com/cmd/derper
|
||||
io from bufio+
|
||||
io/fs from crypto/x509+
|
||||
io/ioutil from github.com/mitchellh/go-ps+
|
||||
iter from maps+
|
||||
log from expvar+
|
||||
log/internal from log
|
||||
maps from tailscale.com/ipn+
|
||||
@@ -261,14 +284,14 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
|
||||
net/http from expvar+
|
||||
net/http/httptrace from net/http+
|
||||
net/http/internal from net/http
|
||||
net/http/pprof from tailscale.com/tsweb+
|
||||
net/http/pprof from tailscale.com/tsweb
|
||||
net/netip from go4.org/netipx+
|
||||
net/textproto from golang.org/x/net/http/httpguts+
|
||||
net/url from crypto/x509+
|
||||
os from crypto/rand+
|
||||
os/exec from github.com/coreos/go-iptables/iptables+
|
||||
os/signal from tailscale.com/cmd/derper
|
||||
W os/user from tailscale.com/util/winutil
|
||||
W os/user from tailscale.com/util/winutil+
|
||||
path from github.com/prometheus/client_golang/prometheus/internal+
|
||||
path/filepath from crypto/x509+
|
||||
reflect from crypto/x509+
|
||||
@@ -286,7 +309,10 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
|
||||
sync/atomic from context+
|
||||
syscall from crypto/rand+
|
||||
text/tabwriter from runtime/pprof
|
||||
text/template from html/template
|
||||
text/template/parse from html/template+
|
||||
time from compress/gzip+
|
||||
unicode from bytes+
|
||||
unicode/utf16 from crypto/x509+
|
||||
unicode/utf8 from bufio+
|
||||
unique from net/netip
|
||||
|
||||
@@ -2,6 +2,12 @@
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
// The derper binary is a simple DERP server.
|
||||
//
|
||||
// For more information, see:
|
||||
//
|
||||
// - About: https://tailscale.com/kb/1232/derp-servers
|
||||
// - Protocol & Go docs: https://pkg.go.dev/tailscale.com/derp
|
||||
// - Running a DERP server: https://github.com/tailscale/tailscale/tree/main/cmd/derper#derp
|
||||
package main // import "tailscale.com/cmd/derper"
|
||||
|
||||
import (
|
||||
@@ -13,6 +19,7 @@ import (
|
||||
"expvar"
|
||||
"flag"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"io"
|
||||
"log"
|
||||
"math"
|
||||
@@ -22,6 +29,9 @@ import (
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"runtime"
|
||||
runtimemetrics "runtime/metrics"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
@@ -203,27 +213,23 @@ func main() {
|
||||
tsweb.AddBrowserHeaders(w)
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.WriteHeader(200)
|
||||
io.WriteString(w, `<html><body>
|
||||
<h1>DERP</h1>
|
||||
<p>
|
||||
This is a
|
||||
<a href="https://tailscale.com/">Tailscale</a>
|
||||
<a href="https://pkg.go.dev/tailscale.com/derp">DERP</a>
|
||||
server.
|
||||
</p>
|
||||
`)
|
||||
if !*runDERP {
|
||||
io.WriteString(w, `<p>Status: <b>disabled</b></p>`)
|
||||
}
|
||||
if tsweb.AllowDebugAccess(r) {
|
||||
io.WriteString(w, "<p>Debug info at <a href='/debug/'>/debug/</a>.</p>\n")
|
||||
err := homePageTemplate.Execute(w, templateData{
|
||||
ShowAbuseInfo: validProdHostname.MatchString(*hostname),
|
||||
Disabled: !*runDERP,
|
||||
AllowDebug: tsweb.AllowDebugAccess(r),
|
||||
})
|
||||
if err != nil {
|
||||
if r.Context().Err() == nil {
|
||||
log.Printf("homePageTemplate.Execute: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
}))
|
||||
mux.Handle("/robots.txt", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
tsweb.AddBrowserHeaders(w)
|
||||
io.WriteString(w, "User-agent: *\nDisallow: /\n")
|
||||
}))
|
||||
mux.Handle("/generate_204", http.HandlerFunc(serveNoContent))
|
||||
mux.Handle("/generate_204", http.HandlerFunc(derphttp.ServeNoContent))
|
||||
debug := tsweb.Debugger(mux)
|
||||
debug.KV("TLS hostname", *hostname)
|
||||
debug.KV("Mesh key", s.HasMeshKey())
|
||||
@@ -236,6 +242,20 @@ func main() {
|
||||
}
|
||||
}))
|
||||
debug.Handle("traffic", "Traffic check", http.HandlerFunc(s.ServeDebugTraffic))
|
||||
debug.Handle("set-mutex-profile-fraction", "SetMutexProfileFraction", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
s := r.FormValue("rate")
|
||||
if s == "" || r.Header.Get("Sec-Debug") != "derp" {
|
||||
http.Error(w, "To set, use: curl -HSec-Debug:derp 'http://derp/debug/set-mutex-profile-fraction?rate=100'", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
v, err := strconv.Atoi(s)
|
||||
if err != nil {
|
||||
http.Error(w, "bad rate value", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
old := runtime.SetMutexProfileFraction(v)
|
||||
fmt.Fprintf(w, "mutex changed from %v to %v\n", old, v)
|
||||
}))
|
||||
|
||||
// Longer lived DERP connections send an application layer keepalive. Note
|
||||
// if the keepalive is hit, the user timeout will take precedence over the
|
||||
@@ -309,7 +329,7 @@ func main() {
|
||||
if *httpPort > -1 {
|
||||
go func() {
|
||||
port80mux := http.NewServeMux()
|
||||
port80mux.HandleFunc("/generate_204", serveNoContent)
|
||||
port80mux.HandleFunc("/generate_204", derphttp.ServeNoContent)
|
||||
port80mux.Handle("/", certManager.HTTPHandler(tsweb.Port80Handler{Main: mux}))
|
||||
port80srv := &http.Server{
|
||||
Addr: net.JoinHostPort(listenHost, fmt.Sprintf("%d", *httpPort)),
|
||||
@@ -350,31 +370,6 @@ func main() {
|
||||
}
|
||||
}
|
||||
|
||||
const (
|
||||
noContentChallengeHeader = "X-Tailscale-Challenge"
|
||||
noContentResponseHeader = "X-Tailscale-Response"
|
||||
)
|
||||
|
||||
// For captive portal detection
|
||||
func serveNoContent(w http.ResponseWriter, r *http.Request) {
|
||||
if challenge := r.Header.Get(noContentChallengeHeader); challenge != "" {
|
||||
badChar := strings.IndexFunc(challenge, func(r rune) bool {
|
||||
return !isChallengeChar(r)
|
||||
}) != -1
|
||||
if len(challenge) <= 64 && !badChar {
|
||||
w.Header().Set(noContentResponseHeader, "response "+challenge)
|
||||
}
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func isChallengeChar(c rune) bool {
|
||||
// Semi-randomly chosen as a limited set of valid characters
|
||||
return ('a' <= c && c <= 'z') || ('A' <= c && c <= 'Z') ||
|
||||
('0' <= c && c <= '9') ||
|
||||
c == '.' || c == '-' || c == '_'
|
||||
}
|
||||
|
||||
var validProdHostname = regexp.MustCompile(`^derp([^.]*)\.tailscale\.com\.?$`)
|
||||
|
||||
func prodAutocertHostPolicy(_ context.Context, host string) error {
|
||||
@@ -452,3 +447,65 @@ func (l *rateLimitedListener) Accept() (net.Conn, error) {
|
||||
l.numAccepts.Add(1)
|
||||
return cn, nil
|
||||
}
|
||||
|
||||
func init() {
|
||||
expvar.Publish("go_sync_mutex_wait_seconds", expvar.Func(func() any {
|
||||
const name = "/sync/mutex/wait/total:seconds" // Go 1.20+
|
||||
var s [1]runtimemetrics.Sample
|
||||
s[0].Name = name
|
||||
runtimemetrics.Read(s[:])
|
||||
if v := s[0].Value; v.Kind() == runtimemetrics.KindFloat64 {
|
||||
return v.Float64()
|
||||
}
|
||||
return 0
|
||||
}))
|
||||
}
|
||||
|
||||
type templateData struct {
|
||||
ShowAbuseInfo bool
|
||||
Disabled bool
|
||||
AllowDebug bool
|
||||
}
|
||||
|
||||
// homePageTemplate renders the home page using [templateData].
|
||||
var homePageTemplate = template.Must(template.New("home").Parse(`<html><body>
|
||||
<h1>DERP</h1>
|
||||
<p>
|
||||
This is a <a href="https://tailscale.com/">Tailscale</a> DERP server.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
It provides STUN, interactive connectivity establishment, and relaying of end-to-end encrypted traffic
|
||||
for Tailscale clients.
|
||||
</p>
|
||||
|
||||
{{if .ShowAbuseInfo }}
|
||||
<p>
|
||||
If you suspect abuse, please contact <a href="mailto:security@tailscale.com">security@tailscale.com</a>.
|
||||
</p>
|
||||
{{end}}
|
||||
|
||||
<p>
|
||||
Documentation:
|
||||
</p>
|
||||
|
||||
<ul>
|
||||
{{if .ShowAbuseInfo }}
|
||||
<li><a href="https://tailscale.com/security-policies">Tailscale Security Policies</a></li>
|
||||
<li><a href="https://tailscale.com/tailscale-aup">Tailscale Acceptable Use Policies</a></li>
|
||||
{{end}}
|
||||
<li><a href="https://tailscale.com/kb/1232/derp-servers">About DERP</a></li>
|
||||
<li><a href="https://pkg.go.dev/tailscale.com/derp">Protocol & Go docs</a></li>
|
||||
<li><a href="https://github.com/tailscale/tailscale/tree/main/cmd/derper#derp">How to run a DERP server</a></li>
|
||||
</ul>
|
||||
|
||||
{{if .Disabled}}
|
||||
<p>Status: <b>disabled</b></p>
|
||||
{{end}}
|
||||
|
||||
{{if .AllowDebug}}
|
||||
<p>Debug info at <a href='/debug/'>/debug/</a>.</p>
|
||||
{{end}}
|
||||
</body>
|
||||
</html>
|
||||
`))
|
||||
|
||||
@@ -4,12 +4,15 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"tailscale.com/derp/derphttp"
|
||||
"tailscale.com/tstest/deptest"
|
||||
)
|
||||
|
||||
@@ -76,20 +79,20 @@ func TestNoContent(t *testing.T) {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
req, _ := http.NewRequest("GET", "https://localhost/generate_204", nil)
|
||||
if tt.input != "" {
|
||||
req.Header.Set(noContentChallengeHeader, tt.input)
|
||||
req.Header.Set(derphttp.NoContentChallengeHeader, tt.input)
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
serveNoContent(w, req)
|
||||
derphttp.ServeNoContent(w, req)
|
||||
resp := w.Result()
|
||||
|
||||
if tt.want == "" {
|
||||
if h, found := resp.Header[noContentResponseHeader]; found {
|
||||
if h, found := resp.Header[derphttp.NoContentResponseHeader]; found {
|
||||
t.Errorf("got %+v; expected no response header", h)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if got := resp.Header.Get(noContentResponseHeader); got != tt.want {
|
||||
if got := resp.Header.Get(derphttp.NoContentResponseHeader); got != tt.want {
|
||||
t.Errorf("got %q; want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
@@ -104,6 +107,35 @@ func TestDeps(t *testing.T) {
|
||||
"gvisor.dev/gvisor/pkg/cpuid": "https://github.com/tailscale/tailscale/issues/9756",
|
||||
"gvisor.dev/gvisor/pkg/tcpip": "https://github.com/tailscale/tailscale/issues/9756",
|
||||
"gvisor.dev/gvisor/pkg/tcpip/header": "https://github.com/tailscale/tailscale/issues/9756",
|
||||
"tailscale.com/net/packet": "not needed in derper",
|
||||
"github.com/gaissmai/bart": "not needed in derper",
|
||||
},
|
||||
}.Check(t)
|
||||
}
|
||||
|
||||
func TestTemplate(t *testing.T) {
|
||||
buf := &bytes.Buffer{}
|
||||
err := homePageTemplate.Execute(buf, templateData{
|
||||
ShowAbuseInfo: true,
|
||||
Disabled: true,
|
||||
AllowDebug: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
str := buf.String()
|
||||
if !strings.Contains(str, "If you suspect abuse") {
|
||||
t.Error("Output is missing abuse mailto")
|
||||
}
|
||||
if !strings.Contains(str, "Tailscale Security Policies") {
|
||||
t.Error("Output is missing Tailscale Security Policies link")
|
||||
}
|
||||
if !strings.Contains(str, "Status:") {
|
||||
t.Error("Output is missing disabled status")
|
||||
}
|
||||
if !strings.Contains(str, "Debug info") {
|
||||
t.Error("Output is missing debug info")
|
||||
}
|
||||
fmt.Println(buf.String())
|
||||
}
|
||||
|
||||
@@ -9,14 +9,12 @@ import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"net/netip"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"tailscale.com/derp"
|
||||
"tailscale.com/derp/derphttp"
|
||||
"tailscale.com/net/netmon"
|
||||
"tailscale.com/types/key"
|
||||
"tailscale.com/types/logger"
|
||||
)
|
||||
|
||||
@@ -71,8 +69,8 @@ func startMeshWithHost(s *derp.Server, host string) error {
|
||||
return d.DialContext(ctx, network, addr)
|
||||
})
|
||||
|
||||
add := func(k key.NodePublic, _ netip.AddrPort) { s.AddPacketForwarder(k, c) }
|
||||
remove := func(k key.NodePublic) { s.RemovePacketForwarder(k, c) }
|
||||
add := func(m derp.PeerPresentMessage) { s.AddPacketForwarder(m.Key, c) }
|
||||
remove := func(m derp.PeerGoneMessage) { s.RemovePacketForwarder(m.Peer, c) }
|
||||
go c.RunWatchConnectionLoop(context.Background(), s.PublicKey(), logf, add, remove)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"nhooyr.io/websocket"
|
||||
"github.com/coder/websocket"
|
||||
"tailscale.com/derp"
|
||||
"tailscale.com/net/wsconn"
|
||||
)
|
||||
|
||||
@@ -7,8 +7,6 @@ package main
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"html"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"sort"
|
||||
@@ -31,6 +29,7 @@ var (
|
||||
tlsInterval = flag.Duration("tls-interval", 15*time.Second, "TLS probe interval")
|
||||
bwInterval = flag.Duration("bw-interval", 0, "bandwidth probe interval (0 = no bandwidth probing)")
|
||||
bwSize = flag.Int64("bw-probe-size-bytes", 1_000_000, "bandwidth probe size")
|
||||
regionCode = flag.String("region-code", "", "probe only this region (e.g. 'lax'); if left blank, all regions will be probed")
|
||||
)
|
||||
|
||||
func main() {
|
||||
@@ -49,6 +48,9 @@ func main() {
|
||||
if *bwInterval > 0 {
|
||||
opts = append(opts, prober.WithBandwidthProbing(*bwInterval, *bwSize))
|
||||
}
|
||||
if *regionCode != "" {
|
||||
opts = append(opts, prober.WithRegion(*regionCode))
|
||||
}
|
||||
dp, err := prober.DERP(p, *derpMapURL, opts...)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
@@ -70,8 +72,18 @@ func main() {
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
tsweb.Debugger(mux)
|
||||
mux.HandleFunc("/", http.HandlerFunc(serveFunc(p)))
|
||||
d := tsweb.Debugger(mux)
|
||||
d.Handle("probe-run", "Run a probe", tsweb.StdHandler(tsweb.ReturnHandlerFunc(p.RunHandler), tsweb.HandlerOptions{Logf: log.Printf}))
|
||||
mux.Handle("/", tsweb.StdHandler(p.StatusHandler(
|
||||
prober.WithTitle("DERP Prober"),
|
||||
prober.WithPageLink("Prober metrics", "/debug/varz"),
|
||||
prober.WithProbeLink("Run Probe", "/debug/probe-run?name={{.Name}}"),
|
||||
), tsweb.HandlerOptions{Logf: log.Printf}))
|
||||
mux.Handle("/healthz", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/plain")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte("ok\n"))
|
||||
}))
|
||||
log.Printf("Listening on %s", *listen)
|
||||
log.Fatal(http.ListenAndServe(*listen, mux))
|
||||
}
|
||||
@@ -105,26 +117,3 @@ func getOverallStatus(p *prober.Prober) (o overallStatus) {
|
||||
sort.Strings(o.good)
|
||||
return
|
||||
}
|
||||
|
||||
func serveFunc(p *prober.Prober) func(w http.ResponseWriter, r *http.Request) {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
st := getOverallStatus(p)
|
||||
summary := "All good"
|
||||
if (float64(len(st.bad)) / float64(len(st.bad)+len(st.good))) > 0.25 {
|
||||
// Returning a 500 allows monitoring this server externally and configuring
|
||||
// an alert on HTTP response code.
|
||||
w.WriteHeader(500)
|
||||
summary = fmt.Sprintf("%d problems", len(st.bad))
|
||||
}
|
||||
|
||||
io.WriteString(w, "<html><head><style>.bad { font-weight: bold; color: #700; }</style></head>\n")
|
||||
fmt.Fprintf(w, "<body><h1>derp probe</h1>\n%s:<ul>", summary)
|
||||
for _, s := range st.bad {
|
||||
fmt.Fprintf(w, "<li class=bad>%s</li>\n", html.EscapeString(s))
|
||||
}
|
||||
for _, s := range st.good {
|
||||
fmt.Fprintf(w, "<li>%s</li>\n", html.EscapeString(s))
|
||||
}
|
||||
io.WriteString(w, "</ul></body></html>\n")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,6 +51,7 @@ func main() {
|
||||
|
||||
ctx := context.Background()
|
||||
tsClient := tailscale.NewClient("-", nil)
|
||||
tsClient.UserAgent = "tailscale-get-authkey"
|
||||
tsClient.HTTPClient = credentials.Client(ctx)
|
||||
tsClient.BaseURL = baseURL
|
||||
|
||||
|
||||
@@ -28,19 +28,20 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
rootFlagSet = flag.NewFlagSet("gitops-pusher", flag.ExitOnError)
|
||||
policyFname = rootFlagSet.String("policy-file", "./policy.hujson", "filename for policy file")
|
||||
cacheFname = rootFlagSet.String("cache-file", "./version-cache.json", "filename for the previous known version hash")
|
||||
timeout = rootFlagSet.Duration("timeout", 5*time.Minute, "timeout for the entire CI run")
|
||||
githubSyntax = rootFlagSet.Bool("github-syntax", true, "use GitHub Action error syntax (https://docs.github.com/en/actions/using-workflows/workflow-commands-for-github-actions#setting-an-error-message)")
|
||||
apiServer = rootFlagSet.String("api-server", "api.tailscale.com", "API server to contact")
|
||||
rootFlagSet = flag.NewFlagSet("gitops-pusher", flag.ExitOnError)
|
||||
policyFname = rootFlagSet.String("policy-file", "./policy.hujson", "filename for policy file")
|
||||
cacheFname = rootFlagSet.String("cache-file", "./version-cache.json", "filename for the previous known version hash")
|
||||
timeout = rootFlagSet.Duration("timeout", 5*time.Minute, "timeout for the entire CI run")
|
||||
githubSyntax = rootFlagSet.Bool("github-syntax", true, "use GitHub Action error syntax (https://docs.github.com/en/actions/using-workflows/workflow-commands-for-github-actions#setting-an-error-message)")
|
||||
apiServer = rootFlagSet.String("api-server", "api.tailscale.com", "API server to contact")
|
||||
failOnManualEdits = rootFlagSet.Bool("fail-on-manual-edits", false, "fail if manual edits to the ACLs in the admin panel are detected; when set to false (the default) only a warning is printed")
|
||||
)
|
||||
|
||||
func modifiedExternallyError() {
|
||||
func modifiedExternallyError() error {
|
||||
if *githubSyntax {
|
||||
fmt.Printf("::warning file=%s,line=1,col=1,title=Policy File Modified Externally::The policy file was modified externally in the admin console.\n", *policyFname)
|
||||
return fmt.Errorf("::warning file=%s,line=1,col=1,title=Policy File Modified Externally::The policy file was modified externally in the admin console.", *policyFname)
|
||||
} else {
|
||||
fmt.Printf("The policy file was modified externally in the admin console.\n")
|
||||
return fmt.Errorf("The policy file was modified externally in the admin console.")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,16 +66,22 @@ func apply(cache *Cache, client *http.Client, tailnet, apiKey string) func(conte
|
||||
log.Printf("local: %s", localEtag)
|
||||
log.Printf("cache: %s", cache.PrevETag)
|
||||
|
||||
if cache.PrevETag != controlEtag {
|
||||
modifiedExternallyError()
|
||||
}
|
||||
|
||||
if controlEtag == localEtag {
|
||||
cache.PrevETag = localEtag
|
||||
log.Println("no update needed, doing nothing")
|
||||
return nil
|
||||
}
|
||||
|
||||
if cache.PrevETag != controlEtag {
|
||||
if err := modifiedExternallyError(); err != nil {
|
||||
if *failOnManualEdits {
|
||||
return err
|
||||
} else {
|
||||
fmt.Println(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := applyNewACL(ctx, client, tailnet, apiKey, *policyFname, controlEtag); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -106,15 +113,21 @@ func test(cache *Cache, client *http.Client, tailnet, apiKey string) func(contex
|
||||
log.Printf("local: %s", localEtag)
|
||||
log.Printf("cache: %s", cache.PrevETag)
|
||||
|
||||
if cache.PrevETag != controlEtag {
|
||||
modifiedExternallyError()
|
||||
}
|
||||
|
||||
if controlEtag == localEtag {
|
||||
log.Println("no updates found, doing nothing")
|
||||
return nil
|
||||
}
|
||||
|
||||
if cache.PrevETag != controlEtag {
|
||||
if err := modifiedExternallyError(); err != nil {
|
||||
if *failOnManualEdits {
|
||||
return err
|
||||
} else {
|
||||
fmt.Println(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := testNewACLs(ctx, client, tailnet, apiKey, *policyFname); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -153,11 +153,36 @@ func (n *nameserver) handleFunc() func(w dns.ResponseWriter, r *dns.Msg) {
|
||||
m.Answer = append(m.Answer, rr)
|
||||
}
|
||||
case dns.TypeAAAA:
|
||||
// TODO (irbekrm): implement IPv6 support.
|
||||
// Kubernetes distributions that I am most familiar with
|
||||
// default to IPv4 for Pod CIDR ranges and often many cases don't
|
||||
// support IPv6 at all, so this should not be crucial for now.
|
||||
fallthrough
|
||||
// TODO (irbekrm): add IPv6 support.
|
||||
// The nameserver currently does not support IPv6
|
||||
// (records are not being created for IPv6 Pod addresses).
|
||||
// However, we can expect that some callers will
|
||||
// nevertheless send AAAA queries.
|
||||
// We have to return NOERROR if a query is received for
|
||||
// an AAAA record for a DNS name that we have an A
|
||||
// record for- else the caller might not follow with an
|
||||
// A record query.
|
||||
// https://github.com/tailscale/tailscale/issues/12321
|
||||
// https://datatracker.ietf.org/doc/html/rfc4074
|
||||
q := r.Question[0].Name
|
||||
fqdn, err := dnsname.ToFQDN(q)
|
||||
if err != nil {
|
||||
m = r.SetRcodeFormatError(r)
|
||||
return
|
||||
}
|
||||
// The only supported use of this nameserver is as a
|
||||
// single source of truth for MagicDNS names by
|
||||
// non-tailnet Kubernetes workloads.
|
||||
m.Authoritative = true
|
||||
ips := n.lookupIP4(fqdn)
|
||||
if len(ips) == 0 {
|
||||
// As we are the authoritative nameserver for MagicDNS
|
||||
// names, if we do not have a record for this MagicDNS
|
||||
// name, it does not exist.
|
||||
m = m.SetRcode(r, dns.RcodeNameError)
|
||||
return
|
||||
}
|
||||
m.SetRcode(r, dns.RcodeSuccess)
|
||||
default:
|
||||
log.Printf("[unexpected] nameserver received a query for an unsupported record type: %s", r.Question[0].String())
|
||||
m.SetRcode(r, dns.RcodeNotImplemented)
|
||||
|
||||
@@ -79,7 +79,7 @@ func TestNameserver(t *testing.T) {
|
||||
}},
|
||||
},
|
||||
{
|
||||
name: "AAAA record query",
|
||||
name: "AAAA record query, A record exists",
|
||||
ip4: map[dnsname.FQDN][]net.IP{dnsname.FQDN("foo.bar.com."): {{1, 2, 3, 4}}},
|
||||
query: &dns.Msg{
|
||||
Question: []dns.Question{{Name: "foo.bar.com", Qtype: dns.TypeAAAA}},
|
||||
@@ -88,26 +88,28 @@ func TestNameserver(t *testing.T) {
|
||||
wantResp: &dns.Msg{
|
||||
Question: []dns.Question{{Name: "foo.bar.com", Qtype: dns.TypeAAAA}},
|
||||
MsgHdr: dns.MsgHdr{
|
||||
Id: 1,
|
||||
Rcode: dns.RcodeNotImplemented,
|
||||
Response: true,
|
||||
Opcode: dns.OpcodeQuery,
|
||||
Id: 1,
|
||||
Rcode: dns.RcodeSuccess,
|
||||
Response: true,
|
||||
Opcode: dns.OpcodeQuery,
|
||||
Authoritative: true,
|
||||
}},
|
||||
},
|
||||
{
|
||||
name: "AAAA record query",
|
||||
name: "AAAA record query, A record does not exist",
|
||||
ip4: map[dnsname.FQDN][]net.IP{dnsname.FQDN("foo.bar.com."): {{1, 2, 3, 4}}},
|
||||
query: &dns.Msg{
|
||||
Question: []dns.Question{{Name: "foo.bar.com", Qtype: dns.TypeAAAA}},
|
||||
Question: []dns.Question{{Name: "baz.bar.com", Qtype: dns.TypeAAAA}},
|
||||
MsgHdr: dns.MsgHdr{Id: 1},
|
||||
},
|
||||
wantResp: &dns.Msg{
|
||||
Question: []dns.Question{{Name: "foo.bar.com", Qtype: dns.TypeAAAA}},
|
||||
Question: []dns.Question{{Name: "baz.bar.com", Qtype: dns.TypeAAAA}},
|
||||
MsgHdr: dns.MsgHdr{
|
||||
Id: 1,
|
||||
Rcode: dns.RcodeNotImplemented,
|
||||
Response: true,
|
||||
Opcode: dns.OpcodeQuery,
|
||||
Id: 1,
|
||||
Rcode: dns.RcodeNameError,
|
||||
Response: true,
|
||||
Opcode: dns.OpcodeQuery,
|
||||
Authoritative: true,
|
||||
}},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -13,7 +13,8 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
"errors"
|
||||
|
||||
"go.uber.org/zap"
|
||||
xslices "golang.org/x/exp/slices"
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
@@ -26,6 +27,7 @@ import (
|
||||
"sigs.k8s.io/controller-runtime/pkg/reconcile"
|
||||
tsoperator "tailscale.com/k8s-operator"
|
||||
tsapi "tailscale.com/k8s-operator/apis/v1alpha1"
|
||||
"tailscale.com/kube/kubetypes"
|
||||
"tailscale.com/tstime"
|
||||
"tailscale.com/util/clientmetric"
|
||||
"tailscale.com/util/set"
|
||||
@@ -33,11 +35,8 @@ import (
|
||||
|
||||
const (
|
||||
reasonConnectorCreationFailed = "ConnectorCreationFailed"
|
||||
|
||||
reasonConnectorCreated = "ConnectorCreated"
|
||||
reasonConnectorCleanupFailed = "ConnectorCleanupFailed"
|
||||
reasonConnectorCleanupInProgress = "ConnectorCleanupInProgress"
|
||||
reasonConnectorInvalid = "ConnectorInvalid"
|
||||
reasonConnectorCreated = "ConnectorCreated"
|
||||
reasonConnectorInvalid = "ConnectorInvalid"
|
||||
|
||||
messageConnectorCreationFailed = "Failed creating Connector: %v"
|
||||
messageConnectorInvalid = "Connector is invalid: %v"
|
||||
@@ -60,15 +59,18 @@ type ConnectorReconciler struct {
|
||||
|
||||
subnetRouters set.Slice[types.UID] // for subnet routers gauge
|
||||
exitNodes set.Slice[types.UID] // for exit nodes gauge
|
||||
appConnectors set.Slice[types.UID] // for app connectors gauge
|
||||
}
|
||||
|
||||
var (
|
||||
// gaugeConnectorResources tracks the overall number of Connectors currently managed by this operator instance.
|
||||
gaugeConnectorResources = clientmetric.NewGauge("k8s_connector_resources")
|
||||
gaugeConnectorResources = clientmetric.NewGauge(kubetypes.MetricConnectorResourceCount)
|
||||
// gaugeConnectorSubnetRouterResources tracks the number of Connectors managed by this operator instance that are subnet routers.
|
||||
gaugeConnectorSubnetRouterResources = clientmetric.NewGauge("k8s_connector_subnetrouter_resources")
|
||||
gaugeConnectorSubnetRouterResources = clientmetric.NewGauge(kubetypes.MetricConnectorWithSubnetRouterCount)
|
||||
// gaugeConnectorExitNodeResources tracks the number of Connectors currently managed by this operator instance that are exit nodes.
|
||||
gaugeConnectorExitNodeResources = clientmetric.NewGauge("k8s_connector_exitnode_resources")
|
||||
gaugeConnectorExitNodeResources = clientmetric.NewGauge(kubetypes.MetricConnectorWithExitNodeCount)
|
||||
// gaugeConnectorAppConnectorResources tracks the number of Connectors currently managed by this operator instance that are app connectors.
|
||||
gaugeConnectorAppConnectorResources = clientmetric.NewGauge(kubetypes.MetricConnectorWithAppConnectorCount)
|
||||
)
|
||||
|
||||
func (a *ConnectorReconciler) Reconcile(ctx context.Context, req reconcile.Request) (res reconcile.Result, err error) {
|
||||
@@ -108,15 +110,14 @@ func (a *ConnectorReconciler) Reconcile(ctx context.Context, req reconcile.Reque
|
||||
}
|
||||
|
||||
oldCnStatus := cn.Status.DeepCopy()
|
||||
setStatus := func(cn *tsapi.Connector, conditionType tsapi.ConnectorConditionType, status metav1.ConditionStatus, reason, message string) (reconcile.Result, error) {
|
||||
setStatus := func(cn *tsapi.Connector, _ tsapi.ConditionType, status metav1.ConditionStatus, reason, message string) (reconcile.Result, error) {
|
||||
tsoperator.SetConnectorCondition(cn, tsapi.ConnectorReady, status, reason, message, cn.Generation, a.clock, logger)
|
||||
var updateErr error
|
||||
if !apiequality.Semantic.DeepEqual(oldCnStatus, cn.Status) {
|
||||
// An error encountered here should get returned by the Reconcile function.
|
||||
if updateErr := a.Client.Status().Update(ctx, cn); updateErr != nil {
|
||||
err = errors.Wrap(err, updateErr.Error())
|
||||
}
|
||||
updateErr = a.Client.Status().Update(ctx, cn)
|
||||
}
|
||||
return res, err
|
||||
return res, errors.Join(err, updateErr)
|
||||
}
|
||||
|
||||
if !slices.Contains(cn.Finalizers, FinalizerName) {
|
||||
@@ -152,6 +153,9 @@ func (a *ConnectorReconciler) Reconcile(ctx context.Context, req reconcile.Reque
|
||||
cn.Status.SubnetRoutes = cn.Spec.SubnetRouter.AdvertiseRoutes.Stringify()
|
||||
return setStatus(cn, tsapi.ConnectorReady, metav1.ConditionTrue, reasonConnectorCreated, reasonConnectorCreated)
|
||||
}
|
||||
if cn.Spec.AppConnector != nil {
|
||||
cn.Status.IsAppConnector = true
|
||||
}
|
||||
cn.Status.SubnetRoutes = ""
|
||||
return setStatus(cn, tsapi.ConnectorReady, metav1.ConditionTrue, reasonConnectorCreated, reasonConnectorCreated)
|
||||
}
|
||||
@@ -184,34 +188,68 @@ func (a *ConnectorReconciler) maybeProvisionConnector(ctx context.Context, logge
|
||||
Connector: &connector{
|
||||
isExitNode: cn.Spec.ExitNode,
|
||||
},
|
||||
ProxyClass: proxyClass,
|
||||
ProxyClassName: proxyClass,
|
||||
}
|
||||
|
||||
if cn.Spec.SubnetRouter != nil && len(cn.Spec.SubnetRouter.AdvertiseRoutes) > 0 {
|
||||
sts.Connector.routes = cn.Spec.SubnetRouter.AdvertiseRoutes.Stringify()
|
||||
}
|
||||
|
||||
if cn.Spec.AppConnector != nil {
|
||||
sts.Connector.isAppConnector = true
|
||||
if len(cn.Spec.AppConnector.Routes) != 0 {
|
||||
sts.Connector.routes = cn.Spec.AppConnector.Routes.Stringify()
|
||||
}
|
||||
}
|
||||
|
||||
a.mu.Lock()
|
||||
if sts.Connector.isExitNode {
|
||||
if cn.Spec.ExitNode {
|
||||
a.exitNodes.Add(cn.UID)
|
||||
} else {
|
||||
a.exitNodes.Remove(cn.UID)
|
||||
}
|
||||
if sts.Connector.routes != "" {
|
||||
if cn.Spec.SubnetRouter != nil {
|
||||
a.subnetRouters.Add(cn.GetUID())
|
||||
} else {
|
||||
a.subnetRouters.Remove(cn.GetUID())
|
||||
}
|
||||
if cn.Spec.AppConnector != nil {
|
||||
a.appConnectors.Add(cn.GetUID())
|
||||
} else {
|
||||
a.appConnectors.Remove(cn.GetUID())
|
||||
}
|
||||
a.mu.Unlock()
|
||||
gaugeConnectorSubnetRouterResources.Set(int64(a.subnetRouters.Len()))
|
||||
gaugeConnectorExitNodeResources.Set(int64(a.exitNodes.Len()))
|
||||
gaugeConnectorAppConnectorResources.Set(int64(a.appConnectors.Len()))
|
||||
var connectors set.Slice[types.UID]
|
||||
connectors.AddSlice(a.exitNodes.Slice())
|
||||
connectors.AddSlice(a.subnetRouters.Slice())
|
||||
connectors.AddSlice(a.appConnectors.Slice())
|
||||
gaugeConnectorResources.Set(int64(connectors.Len()))
|
||||
|
||||
_, err := a.ssr.Provision(ctx, logger, sts)
|
||||
return err
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, tsHost, ips, err := a.ssr.DeviceInfo(ctx, crl)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if tsHost == "" {
|
||||
logger.Debugf("no Tailscale hostname known yet, waiting for connector pod to finish auth")
|
||||
// No hostname yet. Wait for the connector pod to auth.
|
||||
cn.Status.TailnetIPs = nil
|
||||
cn.Status.Hostname = ""
|
||||
return nil
|
||||
}
|
||||
|
||||
cn.Status.TailnetIPs = ips
|
||||
cn.Status.Hostname = tsHost
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *ConnectorReconciler) maybeCleanupConnector(ctx context.Context, logger *zap.SugaredLogger, cn *tsapi.Connector) (bool, error) {
|
||||
@@ -230,12 +268,15 @@ func (a *ConnectorReconciler) maybeCleanupConnector(ctx context.Context, logger
|
||||
a.mu.Lock()
|
||||
a.subnetRouters.Remove(cn.UID)
|
||||
a.exitNodes.Remove(cn.UID)
|
||||
a.appConnectors.Remove(cn.UID)
|
||||
a.mu.Unlock()
|
||||
gaugeConnectorExitNodeResources.Set(int64(a.exitNodes.Len()))
|
||||
gaugeConnectorSubnetRouterResources.Set(int64(a.subnetRouters.Len()))
|
||||
gaugeConnectorAppConnectorResources.Set(int64(a.appConnectors.Len()))
|
||||
var connectors set.Slice[types.UID]
|
||||
connectors.AddSlice(a.exitNodes.Slice())
|
||||
connectors.AddSlice(a.subnetRouters.Slice())
|
||||
connectors.AddSlice(a.appConnectors.Slice())
|
||||
gaugeConnectorResources.Set(int64(connectors.Len()))
|
||||
return true, nil
|
||||
}
|
||||
@@ -244,8 +285,14 @@ func (a *ConnectorReconciler) validate(cn *tsapi.Connector) error {
|
||||
// Connector fields are already validated at apply time with CEL validation
|
||||
// on custom resource fields. The checks here are a backup in case the
|
||||
// CEL validation breaks without us noticing.
|
||||
if !(cn.Spec.SubnetRouter != nil || cn.Spec.ExitNode) {
|
||||
return errors.New("invalid spec: a Connector must expose subnet routes or act as an exit node (or both)")
|
||||
if cn.Spec.SubnetRouter == nil && !cn.Spec.ExitNode && cn.Spec.AppConnector == nil {
|
||||
return errors.New("invalid spec: a Connector must be configured as at least one of subnet router, exit node or app connector")
|
||||
}
|
||||
if (cn.Spec.SubnetRouter != nil || cn.Spec.ExitNode) && cn.Spec.AppConnector != nil {
|
||||
return errors.New("invalid spec: a Connector that is configured as an app connector must not be also configured as a subnet router or exit node")
|
||||
}
|
||||
if cn.Spec.AppConnector != nil {
|
||||
return validateAppConnector(cn.Spec.AppConnector)
|
||||
}
|
||||
if cn.Spec.SubnetRouter == nil {
|
||||
return nil
|
||||
@@ -254,19 +301,27 @@ func (a *ConnectorReconciler) validate(cn *tsapi.Connector) error {
|
||||
}
|
||||
|
||||
func validateSubnetRouter(sb *tsapi.SubnetRouter) error {
|
||||
if len(sb.AdvertiseRoutes) < 1 {
|
||||
if len(sb.AdvertiseRoutes) == 0 {
|
||||
return errors.New("invalid subnet router spec: no routes defined")
|
||||
}
|
||||
var err error
|
||||
for _, route := range sb.AdvertiseRoutes {
|
||||
return validateRoutes(sb.AdvertiseRoutes)
|
||||
}
|
||||
|
||||
func validateAppConnector(ac *tsapi.AppConnector) error {
|
||||
return validateRoutes(ac.Routes)
|
||||
}
|
||||
|
||||
func validateRoutes(routes tsapi.Routes) error {
|
||||
var errs []error
|
||||
for _, route := range routes {
|
||||
pfx, e := netip.ParsePrefix(string(route))
|
||||
if e != nil {
|
||||
err = errors.Wrap(err, fmt.Sprintf("route %s is invalid: %v", route, err))
|
||||
errs = append(errs, fmt.Errorf("route %v is invalid: %v", route, e))
|
||||
continue
|
||||
}
|
||||
if pfx.Masked() != pfx {
|
||||
err = errors.Wrap(err, fmt.Sprintf("route %s has non-address bits set; expected %s", pfx, pfx.Masked()))
|
||||
errs = append(errs, fmt.Errorf("route %s has non-address bits set; expected %s", pfx, pfx.Masked()))
|
||||
}
|
||||
}
|
||||
return err
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
|
||||
@@ -8,15 +8,19 @@ package main
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
appsv1 "k8s.io/api/apps/v1"
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/types"
|
||||
"k8s.io/client-go/tools/record"
|
||||
"sigs.k8s.io/controller-runtime/pkg/client/fake"
|
||||
tsapi "tailscale.com/k8s-operator/apis/v1alpha1"
|
||||
"tailscale.com/kube/kubetypes"
|
||||
"tailscale.com/tstest"
|
||||
"tailscale.com/util/mak"
|
||||
)
|
||||
|
||||
func TestConnector(t *testing.T) {
|
||||
@@ -29,7 +33,7 @@ func TestConnector(t *testing.T) {
|
||||
},
|
||||
TypeMeta: metav1.TypeMeta{
|
||||
Kind: tsapi.ConnectorKind,
|
||||
APIVersion: "tailscale.io/v1alpha1",
|
||||
APIVersion: "tailscale.com/v1alpha1",
|
||||
},
|
||||
Spec: tsapi.ConnectorSpec{
|
||||
SubnetRouter: &tsapi.SubnetRouter{
|
||||
@@ -73,10 +77,28 @@ func TestConnector(t *testing.T) {
|
||||
hostname: "test-connector",
|
||||
isExitNode: true,
|
||||
subnetRoutes: "10.40.0.0/14",
|
||||
app: kubetypes.AppConnector,
|
||||
}
|
||||
expectEqual(t, fc, expectedSecret(t, opts), nil)
|
||||
expectEqual(t, fc, expectedSecret(t, fc, opts), nil)
|
||||
expectEqual(t, fc, expectedSTS(t, fc, opts), removeHashAnnotation)
|
||||
|
||||
// Connector status should get updated with the IP/hostname info when available.
|
||||
const hostname = "foo.tailnetxyz.ts.net"
|
||||
mustUpdate(t, fc, "operator-ns", opts.secretName, func(secret *corev1.Secret) {
|
||||
mak.Set(&secret.Data, "device_id", []byte("1234"))
|
||||
mak.Set(&secret.Data, "device_fqdn", []byte(hostname))
|
||||
mak.Set(&secret.Data, "device_ips", []byte(`["127.0.0.1", "::1"]`))
|
||||
})
|
||||
expectReconciled(t, cr, "", "test")
|
||||
cn.Finalizers = append(cn.Finalizers, "tailscale.com/finalizer")
|
||||
cn.Status.IsExitNode = cn.Spec.ExitNode
|
||||
cn.Status.SubnetRoutes = cn.Spec.SubnetRouter.AdvertiseRoutes.Stringify()
|
||||
cn.Status.Hostname = hostname
|
||||
cn.Status.TailnetIPs = []string{"127.0.0.1", "::1"}
|
||||
expectEqual(t, fc, cn, func(o *tsapi.Connector) {
|
||||
o.Status.Conditions = nil
|
||||
})
|
||||
|
||||
// Add another route to be advertised.
|
||||
mustUpdate[tsapi.Connector](t, fc, "", "test", func(conn *tsapi.Connector) {
|
||||
conn.Spec.SubnetRouter.AdvertiseRoutes = []tsapi.Route{"10.40.0.0/14", "10.44.0.0/20"}
|
||||
@@ -151,8 +173,9 @@ func TestConnector(t *testing.T) {
|
||||
parentType: "connector",
|
||||
subnetRoutes: "10.40.0.0/14",
|
||||
hostname: "test-connector",
|
||||
app: kubetypes.AppConnector,
|
||||
}
|
||||
expectEqual(t, fc, expectedSecret(t, opts), nil)
|
||||
expectEqual(t, fc, expectedSecret(t, fc, opts), nil)
|
||||
expectEqual(t, fc, expectedSTS(t, fc, opts), removeHashAnnotation)
|
||||
|
||||
// Add an exit node.
|
||||
@@ -236,8 +259,9 @@ func TestConnectorWithProxyClass(t *testing.T) {
|
||||
hostname: "test-connector",
|
||||
isExitNode: true,
|
||||
subnetRoutes: "10.40.0.0/14",
|
||||
app: kubetypes.AppConnector,
|
||||
}
|
||||
expectEqual(t, fc, expectedSecret(t, opts), nil)
|
||||
expectEqual(t, fc, expectedSecret(t, fc, opts), nil)
|
||||
expectEqual(t, fc, expectedSTS(t, fc, opts), removeHashAnnotation)
|
||||
|
||||
// 2. Update Connector to specify a ProxyClass. ProxyClass is not yet
|
||||
@@ -254,9 +278,9 @@ func TestConnectorWithProxyClass(t *testing.T) {
|
||||
// its resources.
|
||||
mustUpdateStatus(t, fc, "", "custom-metadata", func(pc *tsapi.ProxyClass) {
|
||||
pc.Status = tsapi.ProxyClassStatus{
|
||||
Conditions: []tsapi.ConnectorCondition{{
|
||||
Conditions: []metav1.Condition{{
|
||||
Status: metav1.ConditionTrue,
|
||||
Type: tsapi.ProxyClassready,
|
||||
Type: string(tsapi.ProxyClassReady),
|
||||
ObservedGeneration: pc.Generation,
|
||||
}}}
|
||||
})
|
||||
@@ -274,3 +298,100 @@ func TestConnectorWithProxyClass(t *testing.T) {
|
||||
expectReconciled(t, cr, "", "test")
|
||||
expectEqual(t, fc, expectedSTS(t, fc, opts), removeHashAnnotation)
|
||||
}
|
||||
|
||||
func TestConnectorWithAppConnector(t *testing.T) {
|
||||
// Setup
|
||||
cn := &tsapi.Connector{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test",
|
||||
UID: types.UID("1234-UID"),
|
||||
},
|
||||
TypeMeta: metav1.TypeMeta{
|
||||
Kind: tsapi.ConnectorKind,
|
||||
APIVersion: "tailscale.io/v1alpha1",
|
||||
},
|
||||
Spec: tsapi.ConnectorSpec{
|
||||
AppConnector: &tsapi.AppConnector{},
|
||||
},
|
||||
}
|
||||
fc := fake.NewClientBuilder().
|
||||
WithScheme(tsapi.GlobalScheme).
|
||||
WithObjects(cn).
|
||||
WithStatusSubresource(cn).
|
||||
Build()
|
||||
ft := &fakeTSClient{}
|
||||
zl, err := zap.NewDevelopment()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cl := tstest.NewClock(tstest.ClockOpts{})
|
||||
fr := record.NewFakeRecorder(1)
|
||||
cr := &ConnectorReconciler{
|
||||
Client: fc,
|
||||
clock: cl,
|
||||
ssr: &tailscaleSTSReconciler{
|
||||
Client: fc,
|
||||
tsClient: ft,
|
||||
defaultTags: []string{"tag:k8s"},
|
||||
operatorNamespace: "operator-ns",
|
||||
proxyImage: "tailscale/tailscale",
|
||||
},
|
||||
logger: zl.Sugar(),
|
||||
recorder: fr,
|
||||
}
|
||||
|
||||
// 1. Connector with app connnector is created and becomes ready
|
||||
expectReconciled(t, cr, "", "test")
|
||||
fullName, shortName := findGenName(t, fc, "", "test", "connector")
|
||||
opts := configOpts{
|
||||
stsName: shortName,
|
||||
secretName: fullName,
|
||||
parentType: "connector",
|
||||
hostname: "test-connector",
|
||||
app: kubetypes.AppConnector,
|
||||
isAppConnector: true,
|
||||
}
|
||||
expectEqual(t, fc, expectedSecret(t, fc, opts), nil)
|
||||
expectEqual(t, fc, expectedSTS(t, fc, opts), removeHashAnnotation)
|
||||
// Connector's ready condition should be set to true
|
||||
|
||||
cn.ObjectMeta.Finalizers = append(cn.ObjectMeta.Finalizers, "tailscale.com/finalizer")
|
||||
cn.Status.IsAppConnector = true
|
||||
cn.Status.Conditions = []metav1.Condition{{
|
||||
Type: string(tsapi.ConnectorReady),
|
||||
Status: metav1.ConditionTrue,
|
||||
LastTransitionTime: metav1.Time{Time: cl.Now().Truncate(time.Second)},
|
||||
Reason: reasonConnectorCreated,
|
||||
Message: reasonConnectorCreated,
|
||||
}}
|
||||
expectEqual(t, fc, cn, nil)
|
||||
|
||||
// 2. Connector with invalid app connector routes has status set to invalid
|
||||
mustUpdate[tsapi.Connector](t, fc, "", "test", func(conn *tsapi.Connector) {
|
||||
conn.Spec.AppConnector.Routes = tsapi.Routes{tsapi.Route("1.2.3.4/5")}
|
||||
})
|
||||
cn.Spec.AppConnector.Routes = tsapi.Routes{tsapi.Route("1.2.3.4/5")}
|
||||
expectReconciled(t, cr, "", "test")
|
||||
cn.Status.Conditions = []metav1.Condition{{
|
||||
Type: string(tsapi.ConnectorReady),
|
||||
Status: metav1.ConditionFalse,
|
||||
LastTransitionTime: metav1.Time{Time: cl.Now().Truncate(time.Second)},
|
||||
Reason: reasonConnectorInvalid,
|
||||
Message: "Connector is invalid: route 1.2.3.4/5 has non-address bits set; expected 0.0.0.0/5",
|
||||
}}
|
||||
expectEqual(t, fc, cn, nil)
|
||||
|
||||
// 3. Connector with valid app connnector routes becomes ready
|
||||
mustUpdate[tsapi.Connector](t, fc, "", "test", func(conn *tsapi.Connector) {
|
||||
conn.Spec.AppConnector.Routes = tsapi.Routes{tsapi.Route("10.88.2.21/32")}
|
||||
})
|
||||
cn.Spec.AppConnector.Routes = tsapi.Routes{tsapi.Route("10.88.2.21/32")}
|
||||
cn.Status.Conditions = []metav1.Condition{{
|
||||
Type: string(tsapi.ConnectorReady),
|
||||
Status: metav1.ConditionTrue,
|
||||
LastTransitionTime: metav1.Time{Time: cl.Now().Truncate(time.Second)},
|
||||
Reason: reasonConnectorCreated,
|
||||
Message: reasonConnectorCreated,
|
||||
}}
|
||||
expectReconciled(t, cr, "", "test")
|
||||
}
|
||||
|
||||
1013
cmd/k8s-operator/depaware.txt
Normal file
1013
cmd/k8s-operator/depaware.txt
Normal file
File diff suppressed because it is too large
Load Diff
@@ -77,6 +77,13 @@ spec:
|
||||
value: "{{ .Values.apiServerProxyConfig.mode }}"
|
||||
- name: PROXY_FIREWALL_MODE
|
||||
value: {{ .Values.proxyConfig.firewallMode }}
|
||||
{{- if .Values.proxyConfig.defaultProxyClass }}
|
||||
- name: PROXY_DEFAULT_CLASS
|
||||
value: {{ .Values.proxyConfig.defaultProxyClass }}
|
||||
{{- end }}
|
||||
{{- with .Values.operatorConfig.extraEnv }}
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
volumeMounts:
|
||||
- name: oauth
|
||||
mountPath: /oauth
|
||||
|
||||
@@ -14,19 +14,22 @@ metadata:
|
||||
rules:
|
||||
- apiGroups: [""]
|
||||
resources: ["events", "services", "services/status"]
|
||||
verbs: ["*"]
|
||||
verbs: ["create","delete","deletecollection","get","list","patch","update","watch"]
|
||||
- apiGroups: ["networking.k8s.io"]
|
||||
resources: ["ingresses", "ingresses/status"]
|
||||
verbs: ["*"]
|
||||
verbs: ["create","delete","deletecollection","get","list","patch","update","watch"]
|
||||
- apiGroups: ["networking.k8s.io"]
|
||||
resources: ["ingressclasses"]
|
||||
verbs: ["get", "list", "watch"]
|
||||
- apiGroups: ["tailscale.com"]
|
||||
resources: ["connectors", "connectors/status", "proxyclasses", "proxyclasses/status"]
|
||||
resources: ["connectors", "connectors/status", "proxyclasses", "proxyclasses/status", "proxygroups", "proxygroups/status"]
|
||||
verbs: ["get", "list", "watch", "update"]
|
||||
- apiGroups: ["tailscale.com"]
|
||||
resources: ["dnsconfigs", "dnsconfigs/status"]
|
||||
verbs: ["get", "list", "watch", "update"]
|
||||
- apiGroups: ["tailscale.com"]
|
||||
resources: ["recorders", "recorders/status"]
|
||||
verbs: ["get", "list", "watch", "update"]
|
||||
---
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: ClusterRoleBinding
|
||||
@@ -49,13 +52,19 @@ metadata:
|
||||
rules:
|
||||
- apiGroups: [""]
|
||||
resources: ["secrets", "serviceaccounts", "configmaps"]
|
||||
verbs: ["*"]
|
||||
verbs: ["create","delete","deletecollection","get","list","patch","update","watch"]
|
||||
- apiGroups: [""]
|
||||
resources: ["pods"]
|
||||
verbs: ["get","list","watch"]
|
||||
- apiGroups: ["apps"]
|
||||
resources: ["statefulsets", "deployments"]
|
||||
verbs: ["*"]
|
||||
verbs: ["create","delete","deletecollection","get","list","patch","update","watch"]
|
||||
- apiGroups: ["discovery.k8s.io"]
|
||||
resources: ["endpointslices"]
|
||||
verbs: ["get", "list", "watch"]
|
||||
verbs: ["get", "list", "watch", "create", "update", "deletecollection"]
|
||||
- apiGroups: ["rbac.authorization.k8s.io"]
|
||||
resources: ["roles", "rolebindings"]
|
||||
verbs: ["get", "create", "patch", "update", "list", "watch"]
|
||||
---
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: RoleBinding
|
||||
|
||||
@@ -15,7 +15,10 @@ metadata:
|
||||
rules:
|
||||
- apiGroups: [""]
|
||||
resources: ["secrets"]
|
||||
verbs: ["*"]
|
||||
verbs: ["create","delete","deletecollection","get","list","patch","update","watch"]
|
||||
- apiGroups: [""]
|
||||
resources: ["events"]
|
||||
verbs: ["create", "patch", "get"]
|
||||
---
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: RoleBinding
|
||||
|
||||
@@ -23,6 +23,7 @@ operatorConfig:
|
||||
- "tag:k8s-operator"
|
||||
|
||||
image:
|
||||
# Repository defaults to DockerHub, but images are also synced to ghcr.io/tailscale/k8s-operator.
|
||||
repository: tailscale/k8s-operator
|
||||
# Digest will be prioritized over tag. If neither are set appVersion will be
|
||||
# used.
|
||||
@@ -47,16 +48,24 @@ operatorConfig:
|
||||
|
||||
securityContext: {}
|
||||
|
||||
extraEnv: []
|
||||
# - name: EXTRA_VAR1
|
||||
# value: "value1"
|
||||
# - name: EXTRA_VAR2
|
||||
# value: "value2"
|
||||
|
||||
|
||||
# proxyConfig contains configuraton that will be applied to any ingress/egress
|
||||
# proxies created by the operator.
|
||||
# https://tailscale.com/kb/1236/kubernetes-operator/#cluster-ingress
|
||||
# https://tailscale.com/kb/1236/kubernetes-operator/#cluster-egress
|
||||
# https://tailscale.com/kb/1439/kubernetes-operator-cluster-ingress
|
||||
# https://tailscale.com/kb/1438/kubernetes-operator-cluster-egress
|
||||
# Note that this section contains only a few global configuration options and
|
||||
# will not be updated with more configuration options in the future.
|
||||
# If you need more configuration options, take a look at ProxyClass:
|
||||
# https://tailscale.com/kb/1236/kubernetes-operator#cluster-resource-customization-using-proxyclass-custom-resource
|
||||
# https://tailscale.com/kb/1445/kubernetes-operator-customization#cluster-resource-customization-using-proxyclass-custom-resource
|
||||
proxyConfig:
|
||||
image:
|
||||
# Repository defaults to DockerHub, but images are also synced to ghcr.io/tailscale/tailscale.
|
||||
repository: tailscale/tailscale
|
||||
# Digest will be prioritized over tag. If neither are set appVersion will be
|
||||
# used.
|
||||
@@ -69,10 +78,14 @@ proxyConfig:
|
||||
# Note that if you pass multiple tags to this field via `--set` flag to helm upgrade/install commands you must escape the comma (for example, "tag:k8s-proxies\,tag:prod"). See https://github.com/helm/helm/issues/1556
|
||||
defaultTags: "tag:k8s"
|
||||
firewallMode: auto
|
||||
# If defined, this proxy class will be used as the default proxy class for
|
||||
# service and ingress resources that do not have a proxy class defined. It
|
||||
# does not apply to Connector resources.
|
||||
defaultProxyClass: ""
|
||||
|
||||
# apiServerProxyConfig allows to configure whether the operator should expose
|
||||
# Kubernetes API server.
|
||||
# https://tailscale.com/kb/1236/kubernetes-operator/#accessing-the-kubernetes-control-plane-using-an-api-server-proxy
|
||||
# https://tailscale.com/kb/1437/kubernetes-operator-api-server-proxy
|
||||
apiServerProxyConfig:
|
||||
mode: "false" # "true", "false", "noauth"
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ apiVersion: apiextensions.k8s.io/v1
|
||||
kind: CustomResourceDefinition
|
||||
metadata:
|
||||
annotations:
|
||||
controller-gen.kubebuilder.io/version: v0.13.0
|
||||
controller-gen.kubebuilder.io/version: v0.15.1-0.20240618033008-7824932b0cab
|
||||
name: connectors.tailscale.com
|
||||
spec:
|
||||
group: tailscale.com
|
||||
@@ -24,6 +24,10 @@ spec:
|
||||
jsonPath: .status.isExitNode
|
||||
name: IsExitNode
|
||||
type: string
|
||||
- description: Whether this Connector instance is an app connector.
|
||||
jsonPath: .status.isAppConnector
|
||||
name: IsAppConnector
|
||||
type: string
|
||||
- description: Status of the deployed Connector resources.
|
||||
jsonPath: .status.conditions[?(@.type == "ConnectorReady")].reason
|
||||
name: Status
|
||||
@@ -31,98 +35,227 @@ spec:
|
||||
name: v1alpha1
|
||||
schema:
|
||||
openAPIV3Schema:
|
||||
description: 'Connector defines a Tailscale node that will be deployed in the cluster. The node can be configured to act as a Tailscale subnet router and/or a Tailscale exit node. Connector is a cluster-scoped resource. More info: https://tailscale.com/kb/1236/kubernetes-operator#deploying-exit-nodes-and-subnet-routers-on-kubernetes-using-connector-custom-resource'
|
||||
description: |-
|
||||
Connector defines a Tailscale node that will be deployed in the cluster. The
|
||||
node can be configured to act as a Tailscale subnet router and/or a Tailscale
|
||||
exit node.
|
||||
Connector is a cluster-scoped resource.
|
||||
More info:
|
||||
https://tailscale.com/kb/1441/kubernetes-operator-connector
|
||||
type: object
|
||||
required:
|
||||
- spec
|
||||
properties:
|
||||
apiVersion:
|
||||
description: 'APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources'
|
||||
description: |-
|
||||
APIVersion defines the versioned schema of this representation of an object.
|
||||
Servers should convert recognized schemas to the latest internal value, and
|
||||
may reject unrecognized values.
|
||||
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources
|
||||
type: string
|
||||
kind:
|
||||
description: 'Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds'
|
||||
description: |-
|
||||
Kind is a string value representing the REST resource this object represents.
|
||||
Servers may infer this from the endpoint the client submits requests to.
|
||||
Cannot be updated.
|
||||
In CamelCase.
|
||||
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds
|
||||
type: string
|
||||
metadata:
|
||||
type: object
|
||||
spec:
|
||||
description: 'ConnectorSpec describes the desired Tailscale component. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#spec-and-status'
|
||||
description: |-
|
||||
ConnectorSpec describes the desired Tailscale component.
|
||||
More info:
|
||||
https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#spec-and-status
|
||||
type: object
|
||||
properties:
|
||||
appConnector:
|
||||
description: |-
|
||||
AppConnector defines whether the Connector device should act as a Tailscale app connector. A Connector that is
|
||||
configured as an app connector cannot be a subnet router or an exit node. If this field is unset, the
|
||||
Connector does not act as an app connector.
|
||||
Note that you will need to manually configure the permissions and the domains for the app connector via the
|
||||
Admin panel.
|
||||
Note also that the main tested and supported use case of this config option is to deploy an app connector on
|
||||
Kubernetes to access SaaS applications available on the public internet. Using the app connector to expose
|
||||
cluster workloads or other internal workloads to tailnet might work, but this is not a use case that we have
|
||||
tested or optimised for.
|
||||
If you are using the app connector to access SaaS applications because you need a predictable egress IP that
|
||||
can be whitelisted, it is also your responsibility to ensure that cluster traffic from the connector flows
|
||||
via that predictable IP, for example by enforcing that cluster egress traffic is routed via an egress NAT
|
||||
device with a static IP address.
|
||||
https://tailscale.com/kb/1281/app-connectors
|
||||
type: object
|
||||
properties:
|
||||
routes:
|
||||
description: |-
|
||||
Routes are optional preconfigured routes for the domains routed via the app connector.
|
||||
If not set, routes for the domains will be discovered dynamically.
|
||||
If set, the app connector will immediately be able to route traffic using the preconfigured routes, but may
|
||||
also dynamically discover other routes.
|
||||
https://tailscale.com/kb/1332/apps-best-practices#preconfiguration
|
||||
type: array
|
||||
minItems: 1
|
||||
items:
|
||||
type: string
|
||||
format: cidr
|
||||
exitNode:
|
||||
description: ExitNode defines whether the Connector node should act as a Tailscale exit node. Defaults to false. https://tailscale.com/kb/1103/exit-nodes
|
||||
description: |-
|
||||
ExitNode defines whether the Connector device should act as a Tailscale exit node. Defaults to false.
|
||||
This field is mutually exclusive with the appConnector field.
|
||||
https://tailscale.com/kb/1103/exit-nodes
|
||||
type: boolean
|
||||
hostname:
|
||||
description: Hostname is the tailnet hostname that should be assigned to the Connector node. If unset, hostname defaults to <connector name>-connector. Hostname can contain lower case letters, numbers and dashes, it must not start or end with a dash and must be between 2 and 63 characters long.
|
||||
description: |-
|
||||
Hostname is the tailnet hostname that should be assigned to the
|
||||
Connector node. If unset, hostname defaults to <connector
|
||||
name>-connector. Hostname can contain lower case letters, numbers and
|
||||
dashes, it must not start or end with a dash and must be between 2
|
||||
and 63 characters long.
|
||||
type: string
|
||||
pattern: ^[a-z0-9][a-z0-9-]{0,61}[a-z0-9]$
|
||||
proxyClass:
|
||||
description: ProxyClass is the name of the ProxyClass custom resource that contains configuration options that should be applied to the resources created for this Connector. If unset, the operator will create resources with the default configuration.
|
||||
description: |-
|
||||
ProxyClass is the name of the ProxyClass custom resource that
|
||||
contains configuration options that should be applied to the
|
||||
resources created for this Connector. If unset, the operator will
|
||||
create resources with the default configuration.
|
||||
type: string
|
||||
subnetRouter:
|
||||
description: SubnetRouter defines subnet routes that the Connector node should expose to tailnet. If unset, none are exposed. https://tailscale.com/kb/1019/subnets/
|
||||
description: |-
|
||||
SubnetRouter defines subnet routes that the Connector device should
|
||||
expose to tailnet as a Tailscale subnet router.
|
||||
https://tailscale.com/kb/1019/subnets/
|
||||
If this field is unset, the device does not get configured as a Tailscale subnet router.
|
||||
This field is mutually exclusive with the appConnector field.
|
||||
type: object
|
||||
required:
|
||||
- advertiseRoutes
|
||||
properties:
|
||||
advertiseRoutes:
|
||||
description: AdvertiseRoutes refer to CIDRs that the subnet router should make available. Route values must be strings that represent a valid IPv4 or IPv6 CIDR range. Values can be Tailscale 4via6 subnet routes. https://tailscale.com/kb/1201/4via6-subnets/
|
||||
description: |-
|
||||
AdvertiseRoutes refer to CIDRs that the subnet router should make
|
||||
available. Route values must be strings that represent a valid IPv4
|
||||
or IPv6 CIDR range. Values can be Tailscale 4via6 subnet routes.
|
||||
https://tailscale.com/kb/1201/4via6-subnets/
|
||||
type: array
|
||||
minItems: 1
|
||||
items:
|
||||
type: string
|
||||
format: cidr
|
||||
tags:
|
||||
description: Tags that the Tailscale node will be tagged with. Defaults to [tag:k8s]. To autoapprove the subnet routes or exit node defined by a Connector, you can configure Tailscale ACLs to give these tags the necessary permissions. See https://tailscale.com/kb/1018/acls/#auto-approvers-for-routes-and-exit-nodes. If you specify custom tags here, you must also make the operator an owner of these tags. See https://tailscale.com/kb/1236/kubernetes-operator/#setting-up-the-kubernetes-operator. Tags cannot be changed once a Connector node has been created. Tag values must be in form ^tag:[a-zA-Z][a-zA-Z0-9-]*$.
|
||||
description: |-
|
||||
Tags that the Tailscale node will be tagged with.
|
||||
Defaults to [tag:k8s].
|
||||
To autoapprove the subnet routes or exit node defined by a Connector,
|
||||
you can configure Tailscale ACLs to give these tags the necessary
|
||||
permissions.
|
||||
See https://tailscale.com/kb/1337/acl-syntax#autoapprovers.
|
||||
If you specify custom tags here, you must also make the operator an owner of these tags.
|
||||
See https://tailscale.com/kb/1236/kubernetes-operator/#setting-up-the-kubernetes-operator.
|
||||
Tags cannot be changed once a Connector node has been created.
|
||||
Tag values must be in form ^tag:[a-zA-Z][a-zA-Z0-9-]*$.
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
pattern: ^tag:[a-zA-Z][a-zA-Z0-9-]*$
|
||||
x-kubernetes-validations:
|
||||
- rule: has(self.subnetRouter) || self.exitNode == true
|
||||
message: A Connector needs to be either an exit node or a subnet router, or both.
|
||||
- rule: has(self.subnetRouter) || (has(self.exitNode) && self.exitNode == true) || has(self.appConnector)
|
||||
message: A Connector needs to have at least one of exit node, subnet router or app connector configured.
|
||||
- rule: '!((has(self.subnetRouter) || (has(self.exitNode) && self.exitNode == true)) && has(self.appConnector))'
|
||||
message: The appConnector field is mutually exclusive with exitNode and subnetRouter fields.
|
||||
status:
|
||||
description: ConnectorStatus describes the status of the Connector. This is set and managed by the Tailscale operator.
|
||||
description: |-
|
||||
ConnectorStatus describes the status of the Connector. This is set
|
||||
and managed by the Tailscale operator.
|
||||
type: object
|
||||
properties:
|
||||
conditions:
|
||||
description: List of status conditions to indicate the status of the Connector. Known condition types are `ConnectorReady`.
|
||||
description: |-
|
||||
List of status conditions to indicate the status of the Connector.
|
||||
Known condition types are `ConnectorReady`.
|
||||
type: array
|
||||
items:
|
||||
description: ConnectorCondition contains condition information for a Connector.
|
||||
description: Condition contains details for one aspect of the current state of this API Resource.
|
||||
type: object
|
||||
required:
|
||||
- lastTransitionTime
|
||||
- message
|
||||
- reason
|
||||
- status
|
||||
- type
|
||||
properties:
|
||||
lastTransitionTime:
|
||||
description: LastTransitionTime is the timestamp corresponding to the last status change of this condition.
|
||||
description: |-
|
||||
lastTransitionTime is the last time the condition transitioned from one status to another.
|
||||
This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable.
|
||||
type: string
|
||||
format: date-time
|
||||
message:
|
||||
description: Message is a human readable description of the details of the last transition, complementing reason.
|
||||
description: |-
|
||||
message is a human readable message indicating details about the transition.
|
||||
This may be an empty string.
|
||||
type: string
|
||||
maxLength: 32768
|
||||
observedGeneration:
|
||||
description: If set, this represents the .metadata.generation that the condition was set based upon. For instance, if .metadata.generation is currently 12, but the .status.condition[x].observedGeneration is 9, the condition is out of date with respect to the current state of the Connector.
|
||||
description: |-
|
||||
observedGeneration represents the .metadata.generation that the condition was set based upon.
|
||||
For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date
|
||||
with respect to the current state of the instance.
|
||||
type: integer
|
||||
format: int64
|
||||
minimum: 0
|
||||
reason:
|
||||
description: Reason is a brief machine readable explanation for the condition's last transition.
|
||||
description: |-
|
||||
reason contains a programmatic identifier indicating the reason for the condition's last transition.
|
||||
Producers of specific condition types may define expected values and meanings for this field,
|
||||
and whether the values are considered a guaranteed API.
|
||||
The value should be a CamelCase string.
|
||||
This field may not be empty.
|
||||
type: string
|
||||
maxLength: 1024
|
||||
minLength: 1
|
||||
pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$
|
||||
status:
|
||||
description: Status of the condition, one of ('True', 'False', 'Unknown').
|
||||
description: status of the condition, one of True, False, Unknown.
|
||||
type: string
|
||||
enum:
|
||||
- "True"
|
||||
- "False"
|
||||
- Unknown
|
||||
type:
|
||||
description: Type of the condition, known values are (`SubnetRouterReady`).
|
||||
description: type of condition in CamelCase or in foo.example.com/CamelCase.
|
||||
type: string
|
||||
maxLength: 316
|
||||
pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$
|
||||
x-kubernetes-list-map-keys:
|
||||
- type
|
||||
x-kubernetes-list-type: map
|
||||
hostname:
|
||||
description: |-
|
||||
Hostname is the fully qualified domain name of the Connector node.
|
||||
If MagicDNS is enabled in your tailnet, it is the MagicDNS name of the
|
||||
node.
|
||||
type: string
|
||||
isAppConnector:
|
||||
description: IsAppConnector is set to true if the Connector acts as an app connector.
|
||||
type: boolean
|
||||
isExitNode:
|
||||
description: IsExitNode is set to true if the Connector acts as an exit node.
|
||||
type: boolean
|
||||
subnetRoutes:
|
||||
description: SubnetRoutes are the routes currently exposed to tailnet via this Connector instance.
|
||||
description: |-
|
||||
SubnetRoutes are the routes currently exposed to tailnet via this
|
||||
Connector instance.
|
||||
type: string
|
||||
tailnetIPs:
|
||||
description: |-
|
||||
TailnetIPs is the set of tailnet IP addresses (both IPv4 and IPv6)
|
||||
assigned to the Connector node.
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
served: true
|
||||
storage: true
|
||||
subresources:
|
||||
|
||||
@@ -2,7 +2,7 @@ apiVersion: apiextensions.k8s.io/v1
|
||||
kind: CustomResourceDefinition
|
||||
metadata:
|
||||
annotations:
|
||||
controller-gen.kubebuilder.io/version: v0.13.0
|
||||
controller-gen.kubebuilder.io/version: v0.15.1-0.20240618033008-7824932b0cab
|
||||
name: dnsconfigs.tailscale.com
|
||||
spec:
|
||||
group: tailscale.com
|
||||
@@ -23,72 +23,157 @@ spec:
|
||||
name: v1alpha1
|
||||
schema:
|
||||
openAPIV3Schema:
|
||||
description: |-
|
||||
DNSConfig can be deployed to cluster to make a subset of Tailscale MagicDNS
|
||||
names resolvable by cluster workloads. Use this if: A) you need to refer to
|
||||
tailnet services, exposed to cluster via Tailscale Kubernetes operator egress
|
||||
proxies by the MagicDNS names of those tailnet services (usually because the
|
||||
services run over HTTPS)
|
||||
B) you have exposed a cluster workload to the tailnet using Tailscale Ingress
|
||||
and you also want to refer to the workload from within the cluster over the
|
||||
Ingress's MagicDNS name (usually because you have some callback component
|
||||
that needs to use the same URL as that used by a non-cluster client on
|
||||
tailnet).
|
||||
When a DNSConfig is applied to a cluster, Tailscale Kubernetes operator will
|
||||
deploy a nameserver for ts.net DNS names and automatically populate it with records
|
||||
for any Tailscale egress or Ingress proxies deployed to that cluster.
|
||||
Currently you must manually update your cluster DNS configuration to add the
|
||||
IP address of the deployed nameserver as a ts.net stub nameserver.
|
||||
Instructions for how to do it:
|
||||
https://kubernetes.io/docs/tasks/administer-cluster/dns-custom-nameservers/#configuration-of-stub-domain-and-upstream-nameserver-using-coredns (for CoreDNS),
|
||||
https://cloud.google.com/kubernetes-engine/docs/how-to/kube-dns (for kube-dns).
|
||||
Tailscale Kubernetes operator will write the address of a Service fronting
|
||||
the nameserver to dsnconfig.status.nameserver.ip.
|
||||
DNSConfig is a singleton - you must not create more than one.
|
||||
NB: if you want cluster workloads to be able to refer to Tailscale Ingress
|
||||
using its MagicDNS name, you must also annotate the Ingress resource with
|
||||
tailscale.com/experimental-forward-cluster-traffic-via-ingress annotation to
|
||||
ensure that the proxy created for the Ingress listens on its Pod IP address.
|
||||
NB: Clusters where Pods get assigned IPv6 addresses only are currently not supported.
|
||||
type: object
|
||||
required:
|
||||
- spec
|
||||
properties:
|
||||
apiVersion:
|
||||
description: 'APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources'
|
||||
description: |-
|
||||
APIVersion defines the versioned schema of this representation of an object.
|
||||
Servers should convert recognized schemas to the latest internal value, and
|
||||
may reject unrecognized values.
|
||||
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources
|
||||
type: string
|
||||
kind:
|
||||
description: 'Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds'
|
||||
description: |-
|
||||
Kind is a string value representing the REST resource this object represents.
|
||||
Servers may infer this from the endpoint the client submits requests to.
|
||||
Cannot be updated.
|
||||
In CamelCase.
|
||||
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds
|
||||
type: string
|
||||
metadata:
|
||||
type: object
|
||||
spec:
|
||||
description: |-
|
||||
Spec describes the desired DNS configuration.
|
||||
More info:
|
||||
https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#spec-and-status
|
||||
type: object
|
||||
required:
|
||||
- nameserver
|
||||
properties:
|
||||
nameserver:
|
||||
description: |-
|
||||
Configuration for a nameserver that can resolve ts.net DNS names
|
||||
associated with in-cluster proxies for Tailscale egress Services and
|
||||
Tailscale Ingresses. The operator will always deploy this nameserver
|
||||
when a DNSConfig is applied.
|
||||
type: object
|
||||
properties:
|
||||
image:
|
||||
description: Nameserver image. Defaults to tailscale/k8s-nameserver:unstable.
|
||||
type: object
|
||||
properties:
|
||||
repo:
|
||||
description: Repo defaults to tailscale/k8s-nameserver.
|
||||
type: string
|
||||
tag:
|
||||
description: Tag defaults to unstable.
|
||||
type: string
|
||||
status:
|
||||
description: |-
|
||||
Status describes the status of the DNSConfig. This is set
|
||||
and managed by the Tailscale operator.
|
||||
type: object
|
||||
properties:
|
||||
conditions:
|
||||
type: array
|
||||
items:
|
||||
description: ConnectorCondition contains condition information for a Connector.
|
||||
description: Condition contains details for one aspect of the current state of this API Resource.
|
||||
type: object
|
||||
required:
|
||||
- lastTransitionTime
|
||||
- message
|
||||
- reason
|
||||
- status
|
||||
- type
|
||||
properties:
|
||||
lastTransitionTime:
|
||||
description: LastTransitionTime is the timestamp corresponding to the last status change of this condition.
|
||||
description: |-
|
||||
lastTransitionTime is the last time the condition transitioned from one status to another.
|
||||
This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable.
|
||||
type: string
|
||||
format: date-time
|
||||
message:
|
||||
description: Message is a human readable description of the details of the last transition, complementing reason.
|
||||
description: |-
|
||||
message is a human readable message indicating details about the transition.
|
||||
This may be an empty string.
|
||||
type: string
|
||||
maxLength: 32768
|
||||
observedGeneration:
|
||||
description: If set, this represents the .metadata.generation that the condition was set based upon. For instance, if .metadata.generation is currently 12, but the .status.condition[x].observedGeneration is 9, the condition is out of date with respect to the current state of the Connector.
|
||||
description: |-
|
||||
observedGeneration represents the .metadata.generation that the condition was set based upon.
|
||||
For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date
|
||||
with respect to the current state of the instance.
|
||||
type: integer
|
||||
format: int64
|
||||
minimum: 0
|
||||
reason:
|
||||
description: Reason is a brief machine readable explanation for the condition's last transition.
|
||||
description: |-
|
||||
reason contains a programmatic identifier indicating the reason for the condition's last transition.
|
||||
Producers of specific condition types may define expected values and meanings for this field,
|
||||
and whether the values are considered a guaranteed API.
|
||||
The value should be a CamelCase string.
|
||||
This field may not be empty.
|
||||
type: string
|
||||
maxLength: 1024
|
||||
minLength: 1
|
||||
pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$
|
||||
status:
|
||||
description: Status of the condition, one of ('True', 'False', 'Unknown').
|
||||
description: status of the condition, one of True, False, Unknown.
|
||||
type: string
|
||||
enum:
|
||||
- "True"
|
||||
- "False"
|
||||
- Unknown
|
||||
type:
|
||||
description: Type of the condition, known values are (`SubnetRouterReady`).
|
||||
description: type of condition in CamelCase or in foo.example.com/CamelCase.
|
||||
type: string
|
||||
maxLength: 316
|
||||
pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$
|
||||
x-kubernetes-list-map-keys:
|
||||
- type
|
||||
x-kubernetes-list-type: map
|
||||
nameserver:
|
||||
description: Nameserver describes the status of nameserver cluster resources.
|
||||
type: object
|
||||
properties:
|
||||
ip:
|
||||
description: |-
|
||||
IP is the ClusterIP of the Service fronting the deployed ts.net nameserver.
|
||||
Currently you must manually update your cluster DNS config to add
|
||||
this address as a stub nameserver for ts.net for cluster workloads to be
|
||||
able to resolve MagicDNS names associated with egress or Ingress
|
||||
proxies.
|
||||
The IP address will change if you delete and recreate the DNSConfig.
|
||||
type: string
|
||||
served: true
|
||||
storage: true
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
187
cmd/k8s-operator/deploy/crds/tailscale.com_proxygroups.yaml
Normal file
187
cmd/k8s-operator/deploy/crds/tailscale.com_proxygroups.yaml
Normal file
@@ -0,0 +1,187 @@
|
||||
apiVersion: apiextensions.k8s.io/v1
|
||||
kind: CustomResourceDefinition
|
||||
metadata:
|
||||
annotations:
|
||||
controller-gen.kubebuilder.io/version: v0.15.1-0.20240618033008-7824932b0cab
|
||||
name: proxygroups.tailscale.com
|
||||
spec:
|
||||
group: tailscale.com
|
||||
names:
|
||||
kind: ProxyGroup
|
||||
listKind: ProxyGroupList
|
||||
plural: proxygroups
|
||||
shortNames:
|
||||
- pg
|
||||
singular: proxygroup
|
||||
scope: Cluster
|
||||
versions:
|
||||
- additionalPrinterColumns:
|
||||
- description: Status of the deployed ProxyGroup resources.
|
||||
jsonPath: .status.conditions[?(@.type == "ProxyGroupReady")].reason
|
||||
name: Status
|
||||
type: string
|
||||
name: v1alpha1
|
||||
schema:
|
||||
openAPIV3Schema:
|
||||
type: object
|
||||
required:
|
||||
- spec
|
||||
properties:
|
||||
apiVersion:
|
||||
description: |-
|
||||
APIVersion defines the versioned schema of this representation of an object.
|
||||
Servers should convert recognized schemas to the latest internal value, and
|
||||
may reject unrecognized values.
|
||||
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources
|
||||
type: string
|
||||
kind:
|
||||
description: |-
|
||||
Kind is a string value representing the REST resource this object represents.
|
||||
Servers may infer this from the endpoint the client submits requests to.
|
||||
Cannot be updated.
|
||||
In CamelCase.
|
||||
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds
|
||||
type: string
|
||||
metadata:
|
||||
type: object
|
||||
spec:
|
||||
description: Spec describes the desired ProxyGroup instances.
|
||||
type: object
|
||||
required:
|
||||
- type
|
||||
properties:
|
||||
hostnamePrefix:
|
||||
description: |-
|
||||
HostnamePrefix is the hostname prefix to use for tailnet devices created
|
||||
by the ProxyGroup. Each device will have the integer number from its
|
||||
StatefulSet pod appended to this prefix to form the full hostname.
|
||||
HostnamePrefix can contain lower case letters, numbers and dashes, it
|
||||
must not start with a dash and must be between 1 and 62 characters long.
|
||||
type: string
|
||||
pattern: ^[a-z0-9][a-z0-9-]{0,61}$
|
||||
proxyClass:
|
||||
description: |-
|
||||
ProxyClass is the name of the ProxyClass custom resource that contains
|
||||
configuration options that should be applied to the resources created
|
||||
for this ProxyGroup. If unset, and there is no default ProxyClass
|
||||
configured, the operator will create resources with the default
|
||||
configuration.
|
||||
type: string
|
||||
replicas:
|
||||
description: |-
|
||||
Replicas specifies how many replicas to create the StatefulSet with.
|
||||
Defaults to 2.
|
||||
type: integer
|
||||
format: int32
|
||||
tags:
|
||||
description: |-
|
||||
Tags that the Tailscale devices will be tagged with. Defaults to [tag:k8s].
|
||||
If you specify custom tags here, make sure you also make the operator
|
||||
an owner of these tags.
|
||||
See https://tailscale.com/kb/1236/kubernetes-operator/#setting-up-the-kubernetes-operator.
|
||||
Tags cannot be changed once a ProxyGroup device has been created.
|
||||
Tag values must be in form ^tag:[a-zA-Z][a-zA-Z0-9-]*$.
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
pattern: ^tag:[a-zA-Z][a-zA-Z0-9-]*$
|
||||
type:
|
||||
description: Type of the ProxyGroup proxies. Currently the only supported type is egress.
|
||||
type: string
|
||||
enum:
|
||||
- egress
|
||||
status:
|
||||
description: |-
|
||||
ProxyGroupStatus describes the status of the ProxyGroup resources. This is
|
||||
set and managed by the Tailscale operator.
|
||||
type: object
|
||||
properties:
|
||||
conditions:
|
||||
description: |-
|
||||
List of status conditions to indicate the status of the ProxyGroup
|
||||
resources. Known condition types are `ProxyGroupReady`.
|
||||
type: array
|
||||
items:
|
||||
description: Condition contains details for one aspect of the current state of this API Resource.
|
||||
type: object
|
||||
required:
|
||||
- lastTransitionTime
|
||||
- message
|
||||
- reason
|
||||
- status
|
||||
- type
|
||||
properties:
|
||||
lastTransitionTime:
|
||||
description: |-
|
||||
lastTransitionTime is the last time the condition transitioned from one status to another.
|
||||
This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable.
|
||||
type: string
|
||||
format: date-time
|
||||
message:
|
||||
description: |-
|
||||
message is a human readable message indicating details about the transition.
|
||||
This may be an empty string.
|
||||
type: string
|
||||
maxLength: 32768
|
||||
observedGeneration:
|
||||
description: |-
|
||||
observedGeneration represents the .metadata.generation that the condition was set based upon.
|
||||
For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date
|
||||
with respect to the current state of the instance.
|
||||
type: integer
|
||||
format: int64
|
||||
minimum: 0
|
||||
reason:
|
||||
description: |-
|
||||
reason contains a programmatic identifier indicating the reason for the condition's last transition.
|
||||
Producers of specific condition types may define expected values and meanings for this field,
|
||||
and whether the values are considered a guaranteed API.
|
||||
The value should be a CamelCase string.
|
||||
This field may not be empty.
|
||||
type: string
|
||||
maxLength: 1024
|
||||
minLength: 1
|
||||
pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$
|
||||
status:
|
||||
description: status of the condition, one of True, False, Unknown.
|
||||
type: string
|
||||
enum:
|
||||
- "True"
|
||||
- "False"
|
||||
- Unknown
|
||||
type:
|
||||
description: type of condition in CamelCase or in foo.example.com/CamelCase.
|
||||
type: string
|
||||
maxLength: 316
|
||||
pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$
|
||||
x-kubernetes-list-map-keys:
|
||||
- type
|
||||
x-kubernetes-list-type: map
|
||||
devices:
|
||||
description: List of tailnet devices associated with the ProxyGroup StatefulSet.
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
required:
|
||||
- hostname
|
||||
properties:
|
||||
hostname:
|
||||
description: |-
|
||||
Hostname is the fully qualified domain name of the device.
|
||||
If MagicDNS is enabled in your tailnet, it is the MagicDNS name of the
|
||||
node.
|
||||
type: string
|
||||
tailnetIPs:
|
||||
description: |-
|
||||
TailnetIPs is the set of tailnet IP addresses (both IPv4 and IPv6)
|
||||
assigned to the device.
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
x-kubernetes-list-map-keys:
|
||||
- hostname
|
||||
x-kubernetes-list-type: map
|
||||
served: true
|
||||
storage: true
|
||||
subresources:
|
||||
status: {}
|
||||
1705
cmd/k8s-operator/deploy/crds/tailscale.com_recorders.yaml
Normal file
1705
cmd/k8s-operator/deploy/crds/tailscale.com_recorders.yaml
Normal file
File diff suppressed because it is too large
Load Diff
@@ -3,7 +3,4 @@ kind: DNSConfig
|
||||
metadata:
|
||||
name: ts-dns
|
||||
spec:
|
||||
nameserver:
|
||||
image:
|
||||
repo: tailscale/k8s-nameserver
|
||||
tag: unstable-v1.65
|
||||
nameserver: {}
|
||||
|
||||
@@ -15,3 +15,9 @@ spec:
|
||||
kubernetes.io/os: "linux"
|
||||
imagePullSecrets:
|
||||
- name: "foo"
|
||||
tailscaleContainer:
|
||||
image: "ghcr.io/tailscale/tailscale:v1.64"
|
||||
imagePullPolicy: IfNotPresent
|
||||
tailscaleInitContainer:
|
||||
image: "ghcr.io/tailscale/tailscale:v1.64"
|
||||
imagePullPolicy: IfNotPresent
|
||||
|
||||
7
cmd/k8s-operator/deploy/examples/proxygroup.yaml
Normal file
7
cmd/k8s-operator/deploy/examples/proxygroup.yaml
Normal file
@@ -0,0 +1,7 @@
|
||||
apiVersion: tailscale.com/v1alpha1
|
||||
kind: ProxyGroup
|
||||
metadata:
|
||||
name: egress-proxies
|
||||
spec:
|
||||
type: egress
|
||||
replicas: 3
|
||||
6
cmd/k8s-operator/deploy/examples/recorder.yaml
Normal file
6
cmd/k8s-operator/deploy/examples/recorder.yaml
Normal file
@@ -0,0 +1,6 @@
|
||||
apiVersion: tailscale.com/v1alpha1
|
||||
kind: Recorder
|
||||
metadata:
|
||||
name: recorder
|
||||
spec:
|
||||
enableUI: true
|
||||
File diff suppressed because it is too large
Load Diff
@@ -30,6 +30,14 @@ spec:
|
||||
valueFrom:
|
||||
fieldRef:
|
||||
fieldPath: status.podIP
|
||||
- name: POD_NAME
|
||||
valueFrom:
|
||||
fieldRef:
|
||||
fieldPath: metadata.name
|
||||
- name: POD_UID
|
||||
valueFrom:
|
||||
fieldRef:
|
||||
fieldPath: metadata.uid
|
||||
securityContext:
|
||||
capabilities:
|
||||
add:
|
||||
|
||||
@@ -24,3 +24,11 @@ spec:
|
||||
valueFrom:
|
||||
fieldRef:
|
||||
fieldPath: status.podIP
|
||||
- name: POD_NAME
|
||||
valueFrom:
|
||||
fieldRef:
|
||||
fieldPath: metadata.name
|
||||
- name: POD_UID
|
||||
valueFrom:
|
||||
fieldRef:
|
||||
fieldPath: metadata.uid
|
||||
|
||||
@@ -3,9 +3,6 @@
|
||||
|
||||
//go:build !plan9
|
||||
|
||||
// tailscale-operator provides a way to expose services running in a Kubernetes
|
||||
// cluster to your Tailnet and to make Tailscale nodes available to cluster
|
||||
// workloads
|
||||
package main
|
||||
|
||||
import (
|
||||
@@ -27,6 +24,7 @@ import (
|
||||
operatorutils "tailscale.com/k8s-operator"
|
||||
tsapi "tailscale.com/k8s-operator/apis/v1alpha1"
|
||||
"tailscale.com/util/mak"
|
||||
"tailscale.com/util/set"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -170,36 +168,49 @@ func (dnsRR *dnsRecordsReconciler) maybeProvision(ctx context.Context, headlessS
|
||||
}
|
||||
}
|
||||
|
||||
// Get the Pod IP addresses for the proxy from the EndpointSlice for the
|
||||
// headless Service.
|
||||
// Get the Pod IP addresses for the proxy from the EndpointSlices for
|
||||
// the headless Service. The Service can have multiple EndpointSlices
|
||||
// associated with it, for example in dual-stack clusters.
|
||||
labels := map[string]string{discoveryv1.LabelServiceName: headlessSvc.Name} // https://kubernetes.io/docs/concepts/services-networking/endpoint-slices/#ownership
|
||||
eps, err := getSingleObject[discoveryv1.EndpointSlice](ctx, dnsRR.Client, dnsRR.tsNamespace, labels)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error getting the EndpointSlice for the proxy's headless Service: %w", err)
|
||||
var eps = new(discoveryv1.EndpointSliceList)
|
||||
if err := dnsRR.List(ctx, eps, client.InNamespace(dnsRR.tsNamespace), client.MatchingLabels(labels)); err != nil {
|
||||
return fmt.Errorf("error listing EndpointSlices for the proxy's headless Service: %w", err)
|
||||
}
|
||||
if eps == nil {
|
||||
if len(eps.Items) == 0 {
|
||||
logger.Debugf("proxy's headless Service EndpointSlice does not yet exist. We will reconcile again once it's created")
|
||||
return nil
|
||||
}
|
||||
// An EndpointSlice for a Service can have a list of endpoints that each
|
||||
// Each EndpointSlice for a Service can have a list of endpoints that each
|
||||
// can have multiple addresses - these are the IP addresses of any Pods
|
||||
// selected by that Service. Pick all the IPv4 addresses.
|
||||
ips := make([]string, 0)
|
||||
for _, ep := range eps.Endpoints {
|
||||
for _, ip := range ep.Addresses {
|
||||
if !net.IsIPv4String(ip) {
|
||||
logger.Infof("EndpointSlice contains IP address %q that is not IPv4, ignoring. Currently only IPv4 is supported", ip)
|
||||
} else {
|
||||
ips = append(ips, ip)
|
||||
// It is also possible that multiple EndpointSlices have overlapping addresses.
|
||||
// https://kubernetes.io/docs/concepts/services-networking/endpoint-slices/#duplicate-endpoints
|
||||
ips := make(set.Set[string], 0)
|
||||
for _, slice := range eps.Items {
|
||||
if slice.AddressType != discoveryv1.AddressTypeIPv4 {
|
||||
logger.Infof("EndpointSlice is for AddressType %s, currently only IPv4 address type is supported", slice.AddressType)
|
||||
continue
|
||||
}
|
||||
for _, ep := range slice.Endpoints {
|
||||
if !epIsReady(&ep) {
|
||||
logger.Debugf("Endpoint with addresses %v appears not ready to receive traffic %v", ep.Addresses, ep.Conditions.String())
|
||||
continue
|
||||
}
|
||||
for _, ip := range ep.Addresses {
|
||||
if !net.IsIPv4String(ip) {
|
||||
logger.Infof("EndpointSlice contains IP address %q that is not IPv4, ignoring. Currently only IPv4 is supported", ip)
|
||||
} else {
|
||||
ips.Add(ip)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(ips) == 0 {
|
||||
if ips.Len() == 0 {
|
||||
logger.Debugf("EndpointSlice for the Service contains no IPv4 addresses. We will reconcile again once they are created.")
|
||||
return nil
|
||||
}
|
||||
updateFunc := func(rec *operatorutils.Records) {
|
||||
mak.Set(&rec.IP4, fqdn, ips)
|
||||
mak.Set(&rec.IP4, fqdn, ips.Slice())
|
||||
}
|
||||
if err = dnsRR.updateDNSConfig(ctx, updateFunc); err != nil {
|
||||
return fmt.Errorf("error updating DNS records: %w", err)
|
||||
@@ -207,6 +218,17 @@ func (dnsRR *dnsRecordsReconciler) maybeProvision(ctx context.Context, headlessS
|
||||
return nil
|
||||
}
|
||||
|
||||
// epIsReady reports whether the endpoint is currently in a state to receive new
|
||||
// traffic. As per kube docs, only explicitly set 'false' for 'Ready' or
|
||||
// 'Serving' conditions or explicitly set 'true' for 'Terminating' condition
|
||||
// means that the Endpoint is NOT ready.
|
||||
// https://github.com/kubernetes/kubernetes/blob/60c4c2b2521fb454ce69dee737e3eb91a25e0535/pkg/apis/discovery/types.go#L109-L131
|
||||
func epIsReady(ep *discoveryv1.Endpoint) bool {
|
||||
return (ep.Conditions.Ready == nil || *ep.Conditions.Ready) &&
|
||||
(ep.Conditions.Serving == nil || *ep.Conditions.Serving) &&
|
||||
(ep.Conditions.Terminating == nil || !*ep.Conditions.Terminating)
|
||||
}
|
||||
|
||||
// maybeCleanup ensures that the DNS record for the proxy has been removed from
|
||||
// dnsrecords ConfigMap and the tailscale.com/dns-records-reconciler finalizer
|
||||
// has been removed from the Service. If the record is not found in the
|
||||
|
||||
@@ -8,6 +8,7 @@ package main
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/google/go-cmp/cmp"
|
||||
@@ -87,13 +88,16 @@ func TestDNSRecordsReconciler(t *testing.T) {
|
||||
},
|
||||
}
|
||||
headlessForEgressSvcFQDN := headlessSvcForParent(egressSvcFQDN, "svc") // create the proxy headless Service
|
||||
ep := endpointSliceForService(headlessForEgressSvcFQDN, "10.9.8.7")
|
||||
ep := endpointSliceForService(headlessForEgressSvcFQDN, "10.9.8.7", discoveryv1.AddressTypeIPv4)
|
||||
epv6 := endpointSliceForService(headlessForEgressSvcFQDN, "2600:1900:4011:161:0:d:0:d", discoveryv1.AddressTypeIPv6)
|
||||
|
||||
mustCreate(t, fc, egressSvcFQDN)
|
||||
mustCreate(t, fc, headlessForEgressSvcFQDN)
|
||||
mustCreate(t, fc, ep)
|
||||
mustCreate(t, fc, epv6)
|
||||
expectReconciled(t, dnsRR, "tailscale", "egress-fqdn") // dns-records-reconciler reconcile the headless Service
|
||||
// ConfigMap should now have a record for foo.bar.ts.net -> 10.8.8.7
|
||||
wantHosts := map[string][]string{"foo.bar.ts.net": {"10.9.8.7"}}
|
||||
wantHosts := map[string][]string{"foo.bar.ts.net": {"10.9.8.7"}} // IPv6 endpoint is currently ignored
|
||||
expectHostsRecords(t, fc, wantHosts)
|
||||
|
||||
// 2. DNS record is updated if tailscale.com/tailnet-fqdn annotation's
|
||||
@@ -106,7 +110,7 @@ func TestDNSRecordsReconciler(t *testing.T) {
|
||||
expectHostsRecords(t, fc, wantHosts)
|
||||
|
||||
// 3. DNS record is updated if the IP address of the proxy Pod changes.
|
||||
ep = endpointSliceForService(headlessForEgressSvcFQDN, "10.6.5.4")
|
||||
ep = endpointSliceForService(headlessForEgressSvcFQDN, "10.6.5.4", discoveryv1.AddressTypeIPv4)
|
||||
mustUpdate(t, fc, ep.Namespace, ep.Name, func(ep *discoveryv1.EndpointSlice) {
|
||||
ep.Endpoints[0].Addresses = []string{"10.6.5.4"}
|
||||
})
|
||||
@@ -116,7 +120,7 @@ func TestDNSRecordsReconciler(t *testing.T) {
|
||||
|
||||
// 4. DNS record is created for an ingress proxy configured via Ingress
|
||||
headlessForIngress := headlessSvcForParent(ing, "ingress")
|
||||
ep = endpointSliceForService(headlessForIngress, "10.9.8.7")
|
||||
ep = endpointSliceForService(headlessForIngress, "10.9.8.7", discoveryv1.AddressTypeIPv4)
|
||||
mustCreate(t, fc, headlessForIngress)
|
||||
mustCreate(t, fc, ep)
|
||||
expectReconciled(t, dnsRR, "tailscale", "ts-ingress") // dns-records-reconciler should reconcile the headless Service
|
||||
@@ -140,6 +144,17 @@ func TestDNSRecordsReconciler(t *testing.T) {
|
||||
expectReconciled(t, dnsRR, "tailscale", "ts-ingress")
|
||||
wantHosts["another.ingress.ts.net"] = []string{"7.8.9.10"}
|
||||
expectHostsRecords(t, fc, wantHosts)
|
||||
|
||||
// 7. A not-ready Endpoint is removed from DNS config.
|
||||
mustUpdate(t, fc, ep.Namespace, ep.Name, func(ep *discoveryv1.EndpointSlice) {
|
||||
ep.Endpoints[0].Conditions.Ready = ptr.To(false)
|
||||
ep.Endpoints = append(ep.Endpoints, discoveryv1.Endpoint{
|
||||
Addresses: []string{"1.2.3.4"},
|
||||
})
|
||||
})
|
||||
expectReconciled(t, dnsRR, "tailscale", "ts-ingress")
|
||||
wantHosts["another.ingress.ts.net"] = []string{"1.2.3.4"}
|
||||
expectHostsRecords(t, fc, wantHosts)
|
||||
}
|
||||
|
||||
func headlessSvcForParent(o client.Object, typ string) *corev1.Service {
|
||||
@@ -162,15 +177,21 @@ func headlessSvcForParent(o client.Object, typ string) *corev1.Service {
|
||||
}
|
||||
}
|
||||
|
||||
func endpointSliceForService(svc *corev1.Service, ip string) *discoveryv1.EndpointSlice {
|
||||
func endpointSliceForService(svc *corev1.Service, ip string, fam discoveryv1.AddressType) *discoveryv1.EndpointSlice {
|
||||
return &discoveryv1.EndpointSlice{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: svc.Name,
|
||||
Name: fmt.Sprintf("%s-%s", svc.Name, string(fam)),
|
||||
Namespace: svc.Namespace,
|
||||
Labels: map[string]string{discoveryv1.LabelServiceName: svc.Name},
|
||||
},
|
||||
AddressType: fam,
|
||||
Endpoints: []discoveryv1.Endpoint{{
|
||||
Addresses: []string{ip},
|
||||
Conditions: discoveryv1.EndpointConditions{
|
||||
Ready: ptr.To(true),
|
||||
Serving: ptr.To(true),
|
||||
Terminating: ptr.To(false),
|
||||
},
|
||||
}},
|
||||
}
|
||||
}
|
||||
|
||||
213
cmd/k8s-operator/egress-eps.go
Normal file
213
cmd/k8s-operator/egress-eps.go
Normal file
@@ -0,0 +1,213 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
//go:build !plan9
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"reflect"
|
||||
"strings"
|
||||
|
||||
"go.uber.org/zap"
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
discoveryv1 "k8s.io/api/discovery/v1"
|
||||
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"sigs.k8s.io/controller-runtime/pkg/client"
|
||||
"sigs.k8s.io/controller-runtime/pkg/reconcile"
|
||||
tsoperator "tailscale.com/k8s-operator"
|
||||
"tailscale.com/kube/egressservices"
|
||||
"tailscale.com/types/ptr"
|
||||
)
|
||||
|
||||
// egressEpsReconciler reconciles EndpointSlices for tailnet services exposed to cluster via egress ProxyGroup proxies.
|
||||
type egressEpsReconciler struct {
|
||||
client.Client
|
||||
logger *zap.SugaredLogger
|
||||
tsNamespace string
|
||||
}
|
||||
|
||||
// Reconcile reconciles an EndpointSlice for a tailnet service. It updates the EndpointSlice with the endpoints of
|
||||
// those ProxyGroup Pods that are ready to route traffic to the tailnet service.
|
||||
// It compares tailnet service state stored in egress proxy state Secrets by containerboot with the desired
|
||||
// configuration stored in proxy-cfg ConfigMap to determine if the endpoint is ready.
|
||||
func (er *egressEpsReconciler) Reconcile(ctx context.Context, req reconcile.Request) (res reconcile.Result, err error) {
|
||||
l := er.logger.With("Service", req.NamespacedName)
|
||||
l.Debugf("starting reconcile")
|
||||
defer l.Debugf("reconcile finished")
|
||||
|
||||
eps := new(discoveryv1.EndpointSlice)
|
||||
err = er.Get(ctx, req.NamespacedName, eps)
|
||||
if apierrors.IsNotFound(err) {
|
||||
l.Debugf("EndpointSlice not found")
|
||||
return reconcile.Result{}, nil
|
||||
}
|
||||
if err != nil {
|
||||
return reconcile.Result{}, fmt.Errorf("failed to get EndpointSlice: %w", err)
|
||||
}
|
||||
if !eps.DeletionTimestamp.IsZero() {
|
||||
l.Debugf("EnpointSlice is being deleted")
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// Get the user-created ExternalName Service and use its status conditions to determine whether cluster
|
||||
// resources are set up for this tailnet service.
|
||||
svc := &corev1.Service{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: eps.Labels[LabelParentName],
|
||||
Namespace: eps.Labels[LabelParentNamespace],
|
||||
},
|
||||
}
|
||||
err = er.Get(ctx, client.ObjectKeyFromObject(svc), svc)
|
||||
if apierrors.IsNotFound(err) {
|
||||
l.Infof("ExternalName Service %s/%s not found, perhaps it was deleted", svc.Namespace, svc.Name)
|
||||
return res, nil
|
||||
}
|
||||
if err != nil {
|
||||
return res, fmt.Errorf("error retrieving ExternalName Service: %w", err)
|
||||
}
|
||||
if !tsoperator.EgressServiceIsValidAndConfigured(svc) {
|
||||
l.Infof("Cluster resources for ExternalName Service %s/%s are not yet configured", svc.Namespace, svc.Name)
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// TODO(irbekrm): currently this reconcile loop runs all the checks every time it's triggered, which is
|
||||
// wasteful. Once we have a Ready condition for ExternalName Services for ProxyGroup, use the condition to
|
||||
// determine if a reconcile is needed.
|
||||
|
||||
oldEps := eps.DeepCopy()
|
||||
proxyGroupName := eps.Labels[labelProxyGroup]
|
||||
tailnetSvc := tailnetSvcName(svc)
|
||||
l = l.With("tailnet-service-name", tailnetSvc)
|
||||
|
||||
// Retrieve the desired tailnet service configuration from the ConfigMap.
|
||||
_, cfgs, err := egressSvcsConfigs(ctx, er.Client, proxyGroupName, er.tsNamespace)
|
||||
if err != nil {
|
||||
return res, fmt.Errorf("error retrieving tailnet services configuration: %w", err)
|
||||
}
|
||||
cfg, ok := (*cfgs)[tailnetSvc]
|
||||
if !ok {
|
||||
l.Infof("[unexpected] configuration for tailnet service %s not found", tailnetSvc)
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// Check which Pods in ProxyGroup are ready to route traffic to this
|
||||
// egress service.
|
||||
podList := &corev1.PodList{}
|
||||
if err := er.List(ctx, podList, client.MatchingLabels(pgLabels(proxyGroupName, nil))); err != nil {
|
||||
return res, fmt.Errorf("error listing Pods for ProxyGroup %s: %w", proxyGroupName, err)
|
||||
}
|
||||
newEndpoints := make([]discoveryv1.Endpoint, 0)
|
||||
for _, pod := range podList.Items {
|
||||
ready, err := er.podIsReadyToRouteTraffic(ctx, pod, &cfg, tailnetSvc, l)
|
||||
if err != nil {
|
||||
return res, fmt.Errorf("error verifying if Pod is ready to route traffic: %w", err)
|
||||
}
|
||||
if !ready {
|
||||
continue // maybe next time
|
||||
}
|
||||
podIP, err := podIPv4(&pod) // we currently only support IPv4
|
||||
if err != nil {
|
||||
return res, fmt.Errorf("error determining IPv4 address for Pod: %w", err)
|
||||
}
|
||||
newEndpoints = append(newEndpoints, discoveryv1.Endpoint{
|
||||
Hostname: (*string)(&pod.UID),
|
||||
Addresses: []string{podIP},
|
||||
Conditions: discoveryv1.EndpointConditions{
|
||||
Ready: ptr.To(true),
|
||||
Serving: ptr.To(true),
|
||||
Terminating: ptr.To(false),
|
||||
},
|
||||
})
|
||||
}
|
||||
// Note that Endpoints are being overwritten with the currently valid endpoints so we don't need to explicitly
|
||||
// run a cleanup for deleted Pods etc.
|
||||
eps.Endpoints = newEndpoints
|
||||
if !reflect.DeepEqual(eps, oldEps) {
|
||||
l.Infof("Updating EndpointSlice to ensure traffic is routed to ready proxy Pods")
|
||||
if err := er.Update(ctx, eps); err != nil {
|
||||
return res, fmt.Errorf("error updating EndpointSlice: %w", err)
|
||||
}
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func podIPv4(pod *corev1.Pod) (string, error) {
|
||||
for _, ip := range pod.Status.PodIPs {
|
||||
parsed, err := netip.ParseAddr(ip.IP)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("error parsing IP address %s: %w", ip, err)
|
||||
}
|
||||
if parsed.Is4() {
|
||||
return parsed.String(), nil
|
||||
}
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// podIsReadyToRouteTraffic returns true if it appears that the proxy Pod has configured firewall rules to be able to
|
||||
// route traffic to the given tailnet service. It retrieves the proxy's state Secret and compares the tailnet service
|
||||
// status written there to the desired service configuration.
|
||||
func (er *egressEpsReconciler) podIsReadyToRouteTraffic(ctx context.Context, pod corev1.Pod, cfg *egressservices.Config, tailnetSvcName string, l *zap.SugaredLogger) (bool, error) {
|
||||
l = l.With("proxy_pod", pod.Name)
|
||||
l.Debugf("checking whether proxy is ready to route to egress service")
|
||||
if !pod.DeletionTimestamp.IsZero() {
|
||||
l.Debugf("proxy Pod is being deleted, ignore")
|
||||
return false, nil
|
||||
}
|
||||
podIP, err := podIPv4(&pod)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("error determining Pod IP address: %v", err)
|
||||
}
|
||||
if podIP == "" {
|
||||
l.Infof("[unexpected] Pod does not have an IPv4 address, and IPv6 is not currently supported")
|
||||
return false, nil
|
||||
}
|
||||
stateS := &corev1.Secret{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: pod.Name,
|
||||
Namespace: pod.Namespace,
|
||||
},
|
||||
}
|
||||
err = er.Get(ctx, client.ObjectKeyFromObject(stateS), stateS)
|
||||
if apierrors.IsNotFound(err) {
|
||||
l.Debugf("proxy does not have a state Secret, waiting...")
|
||||
return false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("error getting state Secret: %w", err)
|
||||
}
|
||||
svcStatusBS := stateS.Data[egressservices.KeyEgressServices]
|
||||
if len(svcStatusBS) == 0 {
|
||||
l.Debugf("proxy's state Secret does not contain egress services status, waiting...")
|
||||
return false, nil
|
||||
}
|
||||
svcStatus := &egressservices.Status{}
|
||||
if err := json.Unmarshal(svcStatusBS, svcStatus); err != nil {
|
||||
return false, fmt.Errorf("error unmarshalling egress service status: %w", err)
|
||||
}
|
||||
if !strings.EqualFold(podIP, svcStatus.PodIPv4) {
|
||||
l.Infof("proxy's egress service status is for Pod IP %s, current proxy's Pod IP %s, waiting for the proxy to reconfigure...", svcStatus.PodIPv4, podIP)
|
||||
return false, nil
|
||||
}
|
||||
st, ok := (*svcStatus).Services[tailnetSvcName]
|
||||
if !ok {
|
||||
l.Infof("proxy's state Secret does not have egress service status, waiting...")
|
||||
return false, nil
|
||||
}
|
||||
if !reflect.DeepEqual(cfg.TailnetTarget, st.TailnetTarget) {
|
||||
l.Infof("proxy has configured egress service for tailnet target %v, current target is %v, waiting for proxy to reconfigure...", st.TailnetTarget, cfg.TailnetTarget)
|
||||
return false, nil
|
||||
}
|
||||
if !reflect.DeepEqual(cfg.Ports, st.Ports) {
|
||||
l.Debugf("proxy has configured egress service for ports %#+v, wants ports %#+v, waiting for proxy to reconfigure", st.Ports, cfg.Ports)
|
||||
return false, nil
|
||||
}
|
||||
l.Debugf("proxy is ready to route traffic to egress service")
|
||||
return true, nil
|
||||
}
|
||||
211
cmd/k8s-operator/egress-eps_test.go
Normal file
211
cmd/k8s-operator/egress-eps_test.go
Normal file
@@ -0,0 +1,211 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
//go:build !plan9
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math/rand/v2"
|
||||
"testing"
|
||||
|
||||
"github.com/AlekSi/pointer"
|
||||
"go.uber.org/zap"
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
discoveryv1 "k8s.io/api/discovery/v1"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/types"
|
||||
"sigs.k8s.io/controller-runtime/pkg/client/fake"
|
||||
tsapi "tailscale.com/k8s-operator/apis/v1alpha1"
|
||||
"tailscale.com/kube/egressservices"
|
||||
"tailscale.com/tstest"
|
||||
"tailscale.com/util/mak"
|
||||
)
|
||||
|
||||
func TestTailscaleEgressEndpointSlices(t *testing.T) {
|
||||
clock := tstest.NewClock(tstest.ClockOpts{})
|
||||
svc := &corev1.Service{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test",
|
||||
Namespace: "default",
|
||||
UID: types.UID("1234-UID"),
|
||||
Annotations: map[string]string{
|
||||
AnnotationTailnetTargetFQDN: "foo.bar.ts.net",
|
||||
AnnotationProxyGroup: "foo",
|
||||
},
|
||||
},
|
||||
Spec: corev1.ServiceSpec{
|
||||
ExternalName: "placeholder",
|
||||
Type: corev1.ServiceTypeExternalName,
|
||||
Selector: nil,
|
||||
Ports: []corev1.ServicePort{
|
||||
{
|
||||
Name: "http",
|
||||
Protocol: "TCP",
|
||||
Port: 80,
|
||||
},
|
||||
},
|
||||
},
|
||||
Status: corev1.ServiceStatus{
|
||||
Conditions: []metav1.Condition{
|
||||
condition(tsapi.EgressSvcConfigured, metav1.ConditionTrue, "", "", clock),
|
||||
condition(tsapi.EgressSvcValid, metav1.ConditionTrue, "", "", clock),
|
||||
},
|
||||
},
|
||||
}
|
||||
port := randomPort()
|
||||
cm := configMapForSvc(t, svc, port)
|
||||
fc := fake.NewClientBuilder().
|
||||
WithScheme(tsapi.GlobalScheme).
|
||||
WithObjects(svc, cm).
|
||||
WithStatusSubresource(svc).
|
||||
Build()
|
||||
zl, err := zap.NewDevelopment()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
er := &egressEpsReconciler{
|
||||
Client: fc,
|
||||
logger: zl.Sugar(),
|
||||
tsNamespace: "operator-ns",
|
||||
}
|
||||
eps := &discoveryv1.EndpointSlice{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "foo",
|
||||
Namespace: "operator-ns",
|
||||
Labels: map[string]string{
|
||||
LabelParentName: "test",
|
||||
LabelParentNamespace: "default",
|
||||
labelSvcType: typeEgress,
|
||||
labelProxyGroup: "foo"},
|
||||
},
|
||||
AddressType: discoveryv1.AddressTypeIPv4,
|
||||
}
|
||||
mustCreate(t, fc, eps)
|
||||
|
||||
t.Run("no_proxy_group_resources", func(t *testing.T) {
|
||||
expectReconciled(t, er, "operator-ns", "foo") // should not error
|
||||
})
|
||||
|
||||
t.Run("no_pods_ready_to_route_traffic", func(t *testing.T) {
|
||||
pod, stateS := podAndSecretForProxyGroup("foo")
|
||||
mustCreate(t, fc, pod)
|
||||
mustCreate(t, fc, stateS)
|
||||
expectReconciled(t, er, "operator-ns", "foo") // should not error
|
||||
})
|
||||
|
||||
t.Run("pods_are_ready_to_route_traffic", func(t *testing.T) {
|
||||
pod, stateS := podAndSecretForProxyGroup("foo")
|
||||
stBs := serviceStatusForPodIP(t, svc, pod.Status.PodIPs[0].IP, port)
|
||||
mustUpdate(t, fc, "operator-ns", stateS.Name, func(s *corev1.Secret) {
|
||||
mak.Set(&s.Data, egressservices.KeyEgressServices, stBs)
|
||||
})
|
||||
expectReconciled(t, er, "operator-ns", "foo")
|
||||
eps.Endpoints = append(eps.Endpoints, discoveryv1.Endpoint{
|
||||
Addresses: []string{"10.0.0.1"},
|
||||
Hostname: pointer.To("foo"),
|
||||
Conditions: discoveryv1.EndpointConditions{
|
||||
Serving: pointer.ToBool(true),
|
||||
Ready: pointer.ToBool(true),
|
||||
Terminating: pointer.ToBool(false),
|
||||
},
|
||||
})
|
||||
expectEqual(t, fc, eps, nil)
|
||||
})
|
||||
t.Run("status_does_not_match_pod_ip", func(t *testing.T) {
|
||||
_, stateS := podAndSecretForProxyGroup("foo") // replica Pod has IP 10.0.0.1
|
||||
stBs := serviceStatusForPodIP(t, svc, "10.0.0.2", port) // status is for a Pod with IP 10.0.0.2
|
||||
mustUpdate(t, fc, "operator-ns", stateS.Name, func(s *corev1.Secret) {
|
||||
mak.Set(&s.Data, egressservices.KeyEgressServices, stBs)
|
||||
})
|
||||
expectReconciled(t, er, "operator-ns", "foo")
|
||||
eps.Endpoints = []discoveryv1.Endpoint{}
|
||||
expectEqual(t, fc, eps, nil)
|
||||
})
|
||||
}
|
||||
|
||||
func configMapForSvc(t *testing.T, svc *corev1.Service, p uint16) *corev1.ConfigMap {
|
||||
t.Helper()
|
||||
ports := make(map[egressservices.PortMap]struct{})
|
||||
for _, port := range svc.Spec.Ports {
|
||||
ports[egressservices.PortMap{Protocol: string(port.Protocol), MatchPort: p, TargetPort: uint16(port.Port)}] = struct{}{}
|
||||
}
|
||||
cfg := egressservices.Config{
|
||||
Ports: ports,
|
||||
}
|
||||
if fqdn := svc.Annotations[AnnotationTailnetTargetFQDN]; fqdn != "" {
|
||||
cfg.TailnetTarget = egressservices.TailnetTarget{FQDN: fqdn}
|
||||
}
|
||||
if ip := svc.Annotations[AnnotationTailnetTargetIP]; ip != "" {
|
||||
cfg.TailnetTarget = egressservices.TailnetTarget{IP: ip}
|
||||
}
|
||||
name := tailnetSvcName(svc)
|
||||
cfgs := egressservices.Configs{name: cfg}
|
||||
bs, err := json.Marshal(&cfgs)
|
||||
if err != nil {
|
||||
t.Fatalf("error marshalling config: %v", err)
|
||||
}
|
||||
cm := &corev1.ConfigMap{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: pgEgressCMName(svc.Annotations[AnnotationProxyGroup]),
|
||||
Namespace: "operator-ns",
|
||||
},
|
||||
BinaryData: map[string][]byte{egressservices.KeyEgressServices: bs},
|
||||
}
|
||||
return cm
|
||||
}
|
||||
|
||||
func serviceStatusForPodIP(t *testing.T, svc *corev1.Service, ip string, p uint16) []byte {
|
||||
t.Helper()
|
||||
ports := make(map[egressservices.PortMap]struct{})
|
||||
for _, port := range svc.Spec.Ports {
|
||||
ports[egressservices.PortMap{Protocol: string(port.Protocol), MatchPort: p, TargetPort: uint16(port.Port)}] = struct{}{}
|
||||
}
|
||||
svcSt := egressservices.ServiceStatus{Ports: ports}
|
||||
if fqdn := svc.Annotations[AnnotationTailnetTargetFQDN]; fqdn != "" {
|
||||
svcSt.TailnetTarget = egressservices.TailnetTarget{FQDN: fqdn}
|
||||
}
|
||||
if ip := svc.Annotations[AnnotationTailnetTargetIP]; ip != "" {
|
||||
svcSt.TailnetTarget = egressservices.TailnetTarget{IP: ip}
|
||||
}
|
||||
svcName := tailnetSvcName(svc)
|
||||
st := egressservices.Status{
|
||||
PodIPv4: ip,
|
||||
Services: map[string]*egressservices.ServiceStatus{svcName: &svcSt},
|
||||
}
|
||||
bs, err := json.Marshal(st)
|
||||
if err != nil {
|
||||
t.Fatalf("error marshalling service status: %v", err)
|
||||
}
|
||||
return bs
|
||||
}
|
||||
|
||||
func podAndSecretForProxyGroup(pg string) (*corev1.Pod, *corev1.Secret) {
|
||||
p := &corev1.Pod{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: fmt.Sprintf("%s-0", pg),
|
||||
Namespace: "operator-ns",
|
||||
Labels: pgLabels(pg, nil),
|
||||
UID: "foo",
|
||||
},
|
||||
Status: corev1.PodStatus{
|
||||
PodIPs: []corev1.PodIP{
|
||||
{IP: "10.0.0.1"},
|
||||
},
|
||||
},
|
||||
}
|
||||
s := &corev1.Secret{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: fmt.Sprintf("%s-0", pg),
|
||||
Namespace: "operator-ns",
|
||||
Labels: pgSecretLabels(pg, "state"),
|
||||
},
|
||||
}
|
||||
return p, s
|
||||
}
|
||||
|
||||
func randomPort() uint16 {
|
||||
return uint16(rand.Int32N(1000) + 1000)
|
||||
}
|
||||
179
cmd/k8s-operator/egress-services-readiness.go
Normal file
179
cmd/k8s-operator/egress-services-readiness.go
Normal file
@@ -0,0 +1,179 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
//go:build !plan9
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"go.uber.org/zap"
|
||||
appsv1 "k8s.io/api/apps/v1"
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
discoveryv1 "k8s.io/api/discovery/v1"
|
||||
apiequality "k8s.io/apimachinery/pkg/api/equality"
|
||||
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"sigs.k8s.io/controller-runtime/pkg/client"
|
||||
"sigs.k8s.io/controller-runtime/pkg/reconcile"
|
||||
tsoperator "tailscale.com/k8s-operator"
|
||||
tsapi "tailscale.com/k8s-operator/apis/v1alpha1"
|
||||
"tailscale.com/tstime"
|
||||
)
|
||||
|
||||
const (
|
||||
reasonReadinessCheckFailed = "ReadinessCheckFailed"
|
||||
reasonClusterResourcesNotReady = "ClusterResourcesNotReady"
|
||||
reasonNoProxies = "NoProxiesConfigured"
|
||||
reasonNotReady = "NotReadyToRouteTraffic"
|
||||
reasonReady = "ReadyToRouteTraffic"
|
||||
reasonPartiallyReady = "PartiallyReadyToRouteTraffic"
|
||||
msgReadyToRouteTemplate = "%d out of %d replicas are ready to route traffic"
|
||||
)
|
||||
|
||||
type egressSvcsReadinessReconciler struct {
|
||||
client.Client
|
||||
logger *zap.SugaredLogger
|
||||
clock tstime.Clock
|
||||
tsNamespace string
|
||||
}
|
||||
|
||||
// Reconcile reconciles an ExternalName Service that defines a tailnet target to be exposed on a ProxyGroup and sets the
|
||||
// EgressSvcReady condition on it. The condition gets set to true if at least one of the proxies is currently ready to
|
||||
// route traffic to the target. It compares proxy Pod IPs with the endpoints set on the EndpointSlice for the egress
|
||||
// service to determine how many replicas are currently able to route traffic.
|
||||
func (esrr *egressSvcsReadinessReconciler) Reconcile(ctx context.Context, req reconcile.Request) (res reconcile.Result, err error) {
|
||||
l := esrr.logger.With("Service", req.NamespacedName)
|
||||
defer l.Info("reconcile finished")
|
||||
|
||||
svc := new(corev1.Service)
|
||||
if err = esrr.Get(ctx, req.NamespacedName, svc); apierrors.IsNotFound(err) {
|
||||
l.Info("Service not found")
|
||||
return res, nil
|
||||
} else if err != nil {
|
||||
return res, fmt.Errorf("failed to get Service: %w", err)
|
||||
}
|
||||
var (
|
||||
reason, msg string
|
||||
st metav1.ConditionStatus = metav1.ConditionUnknown
|
||||
)
|
||||
oldStatus := svc.Status.DeepCopy()
|
||||
defer func() {
|
||||
tsoperator.SetServiceCondition(svc, tsapi.EgressSvcReady, st, reason, msg, esrr.clock, l)
|
||||
if !apiequality.Semantic.DeepEqual(oldStatus, svc.Status) {
|
||||
err = errors.Join(err, esrr.Status().Update(ctx, svc))
|
||||
}
|
||||
}()
|
||||
|
||||
crl := egressSvcChildResourceLabels(svc)
|
||||
eps, err := getSingleObject[discoveryv1.EndpointSlice](ctx, esrr.Client, esrr.tsNamespace, crl)
|
||||
if err != nil {
|
||||
err = fmt.Errorf("error getting EndpointSlice: %w", err)
|
||||
reason = reasonReadinessCheckFailed
|
||||
msg = err.Error()
|
||||
return res, err
|
||||
}
|
||||
if eps == nil {
|
||||
l.Infof("EndpointSlice for Service does not yet exist, waiting...")
|
||||
reason, msg = reasonClusterResourcesNotReady, reasonClusterResourcesNotReady
|
||||
st = metav1.ConditionFalse
|
||||
return res, nil
|
||||
}
|
||||
pg := &tsapi.ProxyGroup{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: svc.Annotations[AnnotationProxyGroup],
|
||||
},
|
||||
}
|
||||
err = esrr.Get(ctx, client.ObjectKeyFromObject(pg), pg)
|
||||
if apierrors.IsNotFound(err) {
|
||||
l.Infof("ProxyGroup for Service does not exist, waiting...")
|
||||
reason, msg = reasonClusterResourcesNotReady, reasonClusterResourcesNotReady
|
||||
st = metav1.ConditionFalse
|
||||
return res, nil
|
||||
}
|
||||
if err != nil {
|
||||
err = fmt.Errorf("error retrieving ProxyGroup: %w", err)
|
||||
reason = reasonReadinessCheckFailed
|
||||
msg = err.Error()
|
||||
return res, err
|
||||
}
|
||||
if !tsoperator.ProxyGroupIsReady(pg) {
|
||||
l.Infof("ProxyGroup for Service is not ready, waiting...")
|
||||
reason, msg = reasonClusterResourcesNotReady, reasonClusterResourcesNotReady
|
||||
st = metav1.ConditionFalse
|
||||
return res, nil
|
||||
}
|
||||
|
||||
replicas := pgReplicas(pg)
|
||||
if replicas == 0 {
|
||||
l.Infof("ProxyGroup replicas set to 0")
|
||||
reason, msg = reasonNoProxies, reasonNoProxies
|
||||
st = metav1.ConditionFalse
|
||||
return res, nil
|
||||
}
|
||||
podLabels := pgLabels(pg.Name, nil)
|
||||
var readyReplicas int32
|
||||
for i := range replicas {
|
||||
podLabels[appsv1.PodIndexLabel] = fmt.Sprintf("%d", i)
|
||||
pod, err := getSingleObject[corev1.Pod](ctx, esrr.Client, esrr.tsNamespace, podLabels)
|
||||
if err != nil {
|
||||
err = fmt.Errorf("error retrieving ProxyGroup Pod: %w", err)
|
||||
reason = reasonReadinessCheckFailed
|
||||
msg = err.Error()
|
||||
return res, err
|
||||
}
|
||||
if pod == nil {
|
||||
l.Infof("[unexpected] ProxyGroup is ready, but replica %d was not found", i)
|
||||
reason, msg = reasonClusterResourcesNotReady, reasonClusterResourcesNotReady
|
||||
return res, nil
|
||||
}
|
||||
l.Infof("looking at Pod with IPs %v", pod.Status.PodIPs)
|
||||
ready := false
|
||||
for _, ep := range eps.Endpoints {
|
||||
l.Infof("looking at endpoint with addresses %v", ep.Addresses)
|
||||
if endpointReadyForPod(&ep, pod, l) {
|
||||
l.Infof("endpoint is ready for Pod")
|
||||
ready = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if ready {
|
||||
readyReplicas++
|
||||
}
|
||||
}
|
||||
msg = fmt.Sprintf(msgReadyToRouteTemplate, readyReplicas, replicas)
|
||||
if readyReplicas == 0 {
|
||||
reason = reasonNotReady
|
||||
st = metav1.ConditionFalse
|
||||
return res, nil
|
||||
}
|
||||
st = metav1.ConditionTrue
|
||||
if readyReplicas < replicas {
|
||||
reason = reasonPartiallyReady
|
||||
} else {
|
||||
reason = reasonReady
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// endpointReadyForPod returns true if the endpoint is for the Pod's IPv4 address and is ready to serve traffic.
|
||||
// Endpoint must not be nil.
|
||||
func endpointReadyForPod(ep *discoveryv1.Endpoint, pod *corev1.Pod, l *zap.SugaredLogger) bool {
|
||||
podIP, err := podIPv4(pod)
|
||||
if err != nil {
|
||||
l.Infof("[unexpected] error retrieving Pod's IPv4 address: %v", err)
|
||||
return false
|
||||
}
|
||||
// Currently we only ever set a single address on and Endpoint and nothing else is meant to modify this.
|
||||
if len(ep.Addresses) != 1 {
|
||||
return false
|
||||
}
|
||||
return strings.EqualFold(ep.Addresses[0], podIP) &&
|
||||
*ep.Conditions.Ready &&
|
||||
*ep.Conditions.Serving &&
|
||||
!*ep.Conditions.Terminating
|
||||
}
|
||||
169
cmd/k8s-operator/egress-services-readiness_test.go
Normal file
169
cmd/k8s-operator/egress-services-readiness_test.go
Normal file
@@ -0,0 +1,169 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
//go:build !plan9
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/AlekSi/pointer"
|
||||
"go.uber.org/zap"
|
||||
appsv1 "k8s.io/api/apps/v1"
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
discoveryv1 "k8s.io/api/discovery/v1"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"sigs.k8s.io/controller-runtime/pkg/client/fake"
|
||||
tsoperator "tailscale.com/k8s-operator"
|
||||
tsapi "tailscale.com/k8s-operator/apis/v1alpha1"
|
||||
"tailscale.com/tstest"
|
||||
"tailscale.com/tstime"
|
||||
)
|
||||
|
||||
func TestEgressServiceReadiness(t *testing.T) {
|
||||
// We need to pass a ProxyGroup object to WithStatusSubresource because of some quirks in how the fake client
|
||||
// works. Without this code further down would not be able to update ProxyGroup status.
|
||||
fc := fake.NewClientBuilder().
|
||||
WithScheme(tsapi.GlobalScheme).
|
||||
WithStatusSubresource(&tsapi.ProxyGroup{}).
|
||||
Build()
|
||||
zl, _ := zap.NewDevelopment()
|
||||
cl := tstest.NewClock(tstest.ClockOpts{})
|
||||
rec := &egressSvcsReadinessReconciler{
|
||||
tsNamespace: "operator-ns",
|
||||
Client: fc,
|
||||
logger: zl.Sugar(),
|
||||
clock: cl,
|
||||
}
|
||||
tailnetFQDN := "my-app.tailnetxyz.ts.net"
|
||||
egressSvc := &corev1.Service{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "my-app",
|
||||
Namespace: "dev",
|
||||
Annotations: map[string]string{
|
||||
AnnotationProxyGroup: "dev",
|
||||
AnnotationTailnetTargetFQDN: tailnetFQDN,
|
||||
},
|
||||
},
|
||||
}
|
||||
fakeClusterIPSvc := &corev1.Service{ObjectMeta: metav1.ObjectMeta{Name: "my-app", Namespace: "operator-ns"}}
|
||||
l := egressSvcEpsLabels(egressSvc, fakeClusterIPSvc)
|
||||
eps := &discoveryv1.EndpointSlice{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "my-app",
|
||||
Namespace: "operator-ns",
|
||||
Labels: l,
|
||||
},
|
||||
AddressType: discoveryv1.AddressTypeIPv4,
|
||||
}
|
||||
pg := &tsapi.ProxyGroup{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "dev",
|
||||
},
|
||||
}
|
||||
mustCreate(t, fc, egressSvc)
|
||||
setClusterNotReady(egressSvc, cl, zl.Sugar())
|
||||
t.Run("endpointslice_does_not_exist", func(t *testing.T) {
|
||||
expectReconciled(t, rec, "dev", "my-app")
|
||||
expectEqual(t, fc, egressSvc, nil) // not ready
|
||||
})
|
||||
t.Run("proxy_group_does_not_exist", func(t *testing.T) {
|
||||
mustCreate(t, fc, eps)
|
||||
expectReconciled(t, rec, "dev", "my-app")
|
||||
expectEqual(t, fc, egressSvc, nil) // still not ready
|
||||
})
|
||||
t.Run("proxy_group_not_ready", func(t *testing.T) {
|
||||
mustCreate(t, fc, pg)
|
||||
expectReconciled(t, rec, "dev", "my-app")
|
||||
expectEqual(t, fc, egressSvc, nil) // still not ready
|
||||
})
|
||||
t.Run("no_ready_replicas", func(t *testing.T) {
|
||||
setPGReady(pg, cl, zl.Sugar())
|
||||
mustUpdateStatus(t, fc, pg.Namespace, pg.Name, func(p *tsapi.ProxyGroup) {
|
||||
p.Status = pg.Status
|
||||
})
|
||||
expectEqual(t, fc, pg, nil)
|
||||
for i := range pgReplicas(pg) {
|
||||
p := pod(pg, i)
|
||||
mustCreate(t, fc, p)
|
||||
mustUpdateStatus(t, fc, p.Namespace, p.Name, func(existing *corev1.Pod) {
|
||||
existing.Status.PodIPs = p.Status.PodIPs
|
||||
})
|
||||
}
|
||||
expectReconciled(t, rec, "dev", "my-app")
|
||||
setNotReady(egressSvc, cl, zl.Sugar(), pgReplicas(pg))
|
||||
expectEqual(t, fc, egressSvc, nil) // still not ready
|
||||
})
|
||||
t.Run("one_ready_replica", func(t *testing.T) {
|
||||
setEndpointForReplica(pg, 0, eps)
|
||||
mustUpdate(t, fc, eps.Namespace, eps.Name, func(e *discoveryv1.EndpointSlice) {
|
||||
e.Endpoints = eps.Endpoints
|
||||
})
|
||||
setReady(egressSvc, cl, zl.Sugar(), pgReplicas(pg), 1)
|
||||
expectReconciled(t, rec, "dev", "my-app")
|
||||
expectEqual(t, fc, egressSvc, nil) // partially ready
|
||||
})
|
||||
t.Run("all_replicas_ready", func(t *testing.T) {
|
||||
for i := range pgReplicas(pg) {
|
||||
setEndpointForReplica(pg, i, eps)
|
||||
}
|
||||
mustUpdate(t, fc, eps.Namespace, eps.Name, func(e *discoveryv1.EndpointSlice) {
|
||||
e.Endpoints = eps.Endpoints
|
||||
})
|
||||
setReady(egressSvc, cl, zl.Sugar(), pgReplicas(pg), pgReplicas(pg))
|
||||
expectReconciled(t, rec, "dev", "my-app")
|
||||
expectEqual(t, fc, egressSvc, nil) // ready
|
||||
})
|
||||
}
|
||||
|
||||
func setClusterNotReady(svc *corev1.Service, cl tstime.Clock, l *zap.SugaredLogger) {
|
||||
tsoperator.SetServiceCondition(svc, tsapi.EgressSvcReady, metav1.ConditionFalse, reasonClusterResourcesNotReady, reasonClusterResourcesNotReady, cl, l)
|
||||
}
|
||||
|
||||
func setNotReady(svc *corev1.Service, cl tstime.Clock, l *zap.SugaredLogger, replicas int32) {
|
||||
msg := fmt.Sprintf(msgReadyToRouteTemplate, 0, replicas)
|
||||
tsoperator.SetServiceCondition(svc, tsapi.EgressSvcReady, metav1.ConditionFalse, reasonNotReady, msg, cl, l)
|
||||
}
|
||||
|
||||
func setReady(svc *corev1.Service, cl tstime.Clock, l *zap.SugaredLogger, replicas, readyReplicas int32) {
|
||||
reason := reasonPartiallyReady
|
||||
if readyReplicas == replicas {
|
||||
reason = reasonReady
|
||||
}
|
||||
msg := fmt.Sprintf(msgReadyToRouteTemplate, readyReplicas, replicas)
|
||||
tsoperator.SetServiceCondition(svc, tsapi.EgressSvcReady, metav1.ConditionTrue, reason, msg, cl, l)
|
||||
}
|
||||
|
||||
func setPGReady(pg *tsapi.ProxyGroup, cl tstime.Clock, l *zap.SugaredLogger) {
|
||||
tsoperator.SetProxyGroupCondition(pg, tsapi.ProxyGroupReady, metav1.ConditionTrue, "foo", "foo", pg.Generation, cl, l)
|
||||
}
|
||||
|
||||
func setEndpointForReplica(pg *tsapi.ProxyGroup, ordinal int32, eps *discoveryv1.EndpointSlice) {
|
||||
p := pod(pg, ordinal)
|
||||
eps.Endpoints = append(eps.Endpoints, discoveryv1.Endpoint{
|
||||
Addresses: []string{p.Status.PodIPs[0].IP},
|
||||
Conditions: discoveryv1.EndpointConditions{
|
||||
Ready: pointer.ToBool(true),
|
||||
Serving: pointer.ToBool(true),
|
||||
Terminating: pointer.ToBool(false),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func pod(pg *tsapi.ProxyGroup, ordinal int32) *corev1.Pod {
|
||||
l := pgLabels(pg.Name, nil)
|
||||
l[appsv1.PodIndexLabel] = fmt.Sprintf("%d", ordinal)
|
||||
ip := fmt.Sprintf("10.0.0.%d", ordinal)
|
||||
return &corev1.Pod{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: fmt.Sprintf("%s-%d", pg.Name, ordinal),
|
||||
Namespace: "operator-ns",
|
||||
Labels: l,
|
||||
},
|
||||
Status: corev1.PodStatus{
|
||||
PodIPs: []corev1.PodIP{{IP: ip}},
|
||||
},
|
||||
}
|
||||
}
|
||||
716
cmd/k8s-operator/egress-services.go
Normal file
716
cmd/k8s-operator/egress-services.go
Normal file
@@ -0,0 +1,716 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
//go:build !plan9
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/rand/v2"
|
||||
"reflect"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"go.uber.org/zap"
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
discoveryv1 "k8s.io/api/discovery/v1"
|
||||
apiequality "k8s.io/apimachinery/pkg/api/equality"
|
||||
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/types"
|
||||
"k8s.io/apimachinery/pkg/util/intstr"
|
||||
"k8s.io/apimachinery/pkg/util/sets"
|
||||
"k8s.io/apiserver/pkg/storage/names"
|
||||
"k8s.io/client-go/tools/record"
|
||||
"sigs.k8s.io/controller-runtime/pkg/client"
|
||||
"sigs.k8s.io/controller-runtime/pkg/reconcile"
|
||||
tsoperator "tailscale.com/k8s-operator"
|
||||
tsapi "tailscale.com/k8s-operator/apis/v1alpha1"
|
||||
"tailscale.com/kube/egressservices"
|
||||
"tailscale.com/kube/kubetypes"
|
||||
"tailscale.com/tstime"
|
||||
"tailscale.com/util/clientmetric"
|
||||
"tailscale.com/util/mak"
|
||||
"tailscale.com/util/set"
|
||||
)
|
||||
|
||||
const (
|
||||
reasonEgressSvcInvalid = "EgressSvcInvalid"
|
||||
reasonEgressSvcValid = "EgressSvcValid"
|
||||
reasonEgressSvcCreationFailed = "EgressSvcCreationFailed"
|
||||
reasonProxyGroupNotReady = "ProxyGroupNotReady"
|
||||
|
||||
labelProxyGroup = "tailscale.com/proxy-group"
|
||||
|
||||
labelSvcType = "tailscale.com/svc-type" // ingress or egress
|
||||
typeEgress = "egress"
|
||||
// maxPorts is the maximum number of ports that can be exposed on a
|
||||
// container. In practice this will be ports in range [3000 - 4000). The
|
||||
// high range should make it easier to distinguish container ports from
|
||||
// the tailnet target ports for debugging purposes (i.e when reading
|
||||
// netfilter rules). The limit of 10000 is somewhat arbitrary, the
|
||||
// assumption is that this would not be hit in practice.
|
||||
maxPorts = 10000
|
||||
|
||||
indexEgressProxyGroup = ".metadata.annotations.egress-proxy-group"
|
||||
)
|
||||
|
||||
var gaugeEgressServices = clientmetric.NewGauge(kubetypes.MetricEgressServiceCount)
|
||||
|
||||
// egressSvcsReconciler reconciles user created ExternalName Services that specify a tailnet
|
||||
// endpoint that should be exposed to cluster workloads and an egress ProxyGroup
|
||||
// on whose proxies it should be exposed.
|
||||
type egressSvcsReconciler struct {
|
||||
client.Client
|
||||
logger *zap.SugaredLogger
|
||||
recorder record.EventRecorder
|
||||
clock tstime.Clock
|
||||
tsNamespace string
|
||||
|
||||
mu sync.Mutex // protects following
|
||||
svcs set.Slice[types.UID] // UIDs of all currently managed egress Services for ProxyGroup
|
||||
}
|
||||
|
||||
// Reconcile reconciles an ExternalName Service that specifies a tailnet target and a ProxyGroup on whose proxies should
|
||||
// forward cluster traffic to the target.
|
||||
// For an ExternalName Service the reconciler:
|
||||
//
|
||||
// - for each port N defined on the ExternalName Service, allocates a port X in range [3000- 4000), unique for the
|
||||
// ProxyGroup proxies. Proxies will forward cluster traffic received on port N to port M on the tailnet target
|
||||
//
|
||||
// - creates a ClusterIP Service in the operator's namespace with portmappings for all M->N port pairs. This will allow
|
||||
// cluster workloads to send traffic on the user-defined tailnet target port and get it transparently mapped to the
|
||||
// randomly selected port on proxy Pods.
|
||||
//
|
||||
// - creates an EndpointSlice in the operator's namespace with kubernetes.io/service-name label pointing to the
|
||||
// ClusterIP Service. The endpoints will get dynamically updates to proxy Pod IPs as the Pods become ready to route
|
||||
// traffic to the tailnet target. kubernetes.io/service-name label ensures that kube-proxy sets up routing rules to
|
||||
// forward cluster traffic received on ClusterIP Service's IP address to the endpoints (Pod IPs).
|
||||
//
|
||||
// - updates the egress service config in a ConfigMap mounted to the ProxyGroup proxies with the tailnet target and the
|
||||
// portmappings.
|
||||
func (esr *egressSvcsReconciler) Reconcile(ctx context.Context, req reconcile.Request) (res reconcile.Result, err error) {
|
||||
l := esr.logger.With("Service", req.NamespacedName)
|
||||
defer l.Info("reconcile finished")
|
||||
|
||||
svc := new(corev1.Service)
|
||||
if err = esr.Get(ctx, req.NamespacedName, svc); apierrors.IsNotFound(err) {
|
||||
l.Info("Service not found")
|
||||
return res, nil
|
||||
} else if err != nil {
|
||||
return res, fmt.Errorf("failed to get Service: %w", err)
|
||||
}
|
||||
|
||||
// Name of the 'egress service', meaning the tailnet target.
|
||||
tailnetSvc := tailnetSvcName(svc)
|
||||
l = l.With("tailnet-service", tailnetSvc)
|
||||
|
||||
// Note that resources for egress Services are only cleaned up when the
|
||||
// Service is actually deleted (and not if, for example, user decides to
|
||||
// remove the Tailscale annotation from it). This should be fine- we
|
||||
// assume that the egress ExternalName Services are always created for
|
||||
// Tailscale operator specifically.
|
||||
if !svc.DeletionTimestamp.IsZero() {
|
||||
l.Info("Service is being deleted, ensuring resource cleanup")
|
||||
return res, esr.maybeCleanup(ctx, svc, l)
|
||||
}
|
||||
|
||||
oldStatus := svc.Status.DeepCopy()
|
||||
defer func() {
|
||||
if !apiequality.Semantic.DeepEqual(oldStatus, svc.Status) {
|
||||
err = errors.Join(err, esr.Status().Update(ctx, svc))
|
||||
}
|
||||
}()
|
||||
|
||||
// Validate the user-created ExternalName Service and the associated ProxyGroup.
|
||||
if ok, err := esr.validateClusterResources(ctx, svc, l); err != nil {
|
||||
return res, fmt.Errorf("error validating cluster resources: %w", err)
|
||||
} else if !ok {
|
||||
return res, nil
|
||||
}
|
||||
|
||||
if !slices.Contains(svc.Finalizers, FinalizerName) {
|
||||
l.Infof("configuring tailnet service") // logged exactly once
|
||||
svc.Finalizers = append(svc.Finalizers, FinalizerName)
|
||||
if err := esr.Update(ctx, svc); err != nil {
|
||||
err := fmt.Errorf("failed to add finalizer: %w", err)
|
||||
r := svcConfiguredReason(svc, false, l)
|
||||
tsoperator.SetServiceCondition(svc, tsapi.EgressSvcConfigured, metav1.ConditionFalse, r, err.Error(), esr.clock, l)
|
||||
return res, err
|
||||
}
|
||||
esr.mu.Lock()
|
||||
esr.svcs.Add(svc.UID)
|
||||
gaugeEgressServices.Set(int64(esr.svcs.Len()))
|
||||
esr.mu.Unlock()
|
||||
}
|
||||
|
||||
if err := esr.maybeCleanupProxyGroupConfig(ctx, svc, l); err != nil {
|
||||
err = fmt.Errorf("cleaning up resources for previous ProxyGroup failed: %w", err)
|
||||
r := svcConfiguredReason(svc, false, l)
|
||||
tsoperator.SetServiceCondition(svc, tsapi.EgressSvcConfigured, metav1.ConditionFalse, r, err.Error(), esr.clock, l)
|
||||
return res, err
|
||||
}
|
||||
|
||||
return res, esr.maybeProvision(ctx, svc, l)
|
||||
}
|
||||
|
||||
func (esr *egressSvcsReconciler) maybeProvision(ctx context.Context, svc *corev1.Service, l *zap.SugaredLogger) (err error) {
|
||||
r := svcConfiguredReason(svc, false, l)
|
||||
st := metav1.ConditionFalse
|
||||
defer func() {
|
||||
msg := r
|
||||
if st != metav1.ConditionTrue && err != nil {
|
||||
msg = err.Error()
|
||||
}
|
||||
tsoperator.SetServiceCondition(svc, tsapi.EgressSvcConfigured, st, r, msg, esr.clock, l)
|
||||
}()
|
||||
|
||||
crl := egressSvcChildResourceLabels(svc)
|
||||
clusterIPSvc, err := getSingleObject[corev1.Service](ctx, esr.Client, esr.tsNamespace, crl)
|
||||
if err != nil {
|
||||
err = fmt.Errorf("error retrieving ClusterIP Service: %w", err)
|
||||
return err
|
||||
}
|
||||
if clusterIPSvc == nil {
|
||||
clusterIPSvc = esr.clusterIPSvcForEgress(crl)
|
||||
}
|
||||
upToDate := svcConfigurationUpToDate(svc, l)
|
||||
provisioned := true
|
||||
if !upToDate {
|
||||
if clusterIPSvc, provisioned, err = esr.provision(ctx, svc.Annotations[AnnotationProxyGroup], svc, clusterIPSvc, l); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if !provisioned {
|
||||
l.Infof("unable to provision cluster resources")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Update ExternalName Service to point at the ClusterIP Service.
|
||||
clusterDomain := retrieveClusterDomain(esr.tsNamespace, l)
|
||||
clusterIPSvcFQDN := fmt.Sprintf("%s.%s.svc.%s", clusterIPSvc.Name, clusterIPSvc.Namespace, clusterDomain)
|
||||
if svc.Spec.ExternalName != clusterIPSvcFQDN {
|
||||
l.Infof("Configuring ExternalName Service to point to ClusterIP Service %s", clusterIPSvcFQDN)
|
||||
svc.Spec.ExternalName = clusterIPSvcFQDN
|
||||
if err = esr.Update(ctx, svc); err != nil {
|
||||
err = fmt.Errorf("error updating ExternalName Service: %w", err)
|
||||
return err
|
||||
}
|
||||
}
|
||||
r = svcConfiguredReason(svc, true, l)
|
||||
st = metav1.ConditionTrue
|
||||
return nil
|
||||
}
|
||||
|
||||
func (esr *egressSvcsReconciler) provision(ctx context.Context, proxyGroupName string, svc, clusterIPSvc *corev1.Service, l *zap.SugaredLogger) (*corev1.Service, bool, error) {
|
||||
l.Infof("updating configuration...")
|
||||
usedPorts, err := esr.usedPortsForPG(ctx, proxyGroupName)
|
||||
if err != nil {
|
||||
return nil, false, fmt.Errorf("error calculating used ports for ProxyGroup %s: %w", proxyGroupName, err)
|
||||
}
|
||||
|
||||
oldClusterIPSvc := clusterIPSvc.DeepCopy()
|
||||
// loop over ClusterIP Service ports, remove any that are not needed.
|
||||
for i := len(clusterIPSvc.Spec.Ports) - 1; i >= 0; i-- {
|
||||
pm := clusterIPSvc.Spec.Ports[i]
|
||||
found := false
|
||||
for _, wantsPM := range svc.Spec.Ports {
|
||||
if wantsPM.Port == pm.Port && strings.EqualFold(string(wantsPM.Protocol), string(pm.Protocol)) {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
l.Debugf("portmapping %s:%d -> %s:%d is no longer required, removing", pm.Protocol, pm.TargetPort.IntVal, pm.Protocol, pm.Port)
|
||||
clusterIPSvc.Spec.Ports = slices.Delete(clusterIPSvc.Spec.Ports, i, i+1)
|
||||
}
|
||||
}
|
||||
|
||||
// loop over ExternalName Service ports, for each one not found on
|
||||
// ClusterIP Service produce new target port and add a portmapping to
|
||||
// the ClusterIP Service.
|
||||
for _, wantsPM := range svc.Spec.Ports {
|
||||
found := false
|
||||
for _, gotPM := range clusterIPSvc.Spec.Ports {
|
||||
if wantsPM.Port == gotPM.Port && strings.EqualFold(string(wantsPM.Protocol), string(gotPM.Protocol)) {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
// Calculate a free port to expose on container and add
|
||||
// a new PortMap to the ClusterIP Service.
|
||||
if usedPorts.Len() == maxPorts {
|
||||
// TODO(irbekrm): refactor to avoid extra reconciles here. Low priority as in practice,
|
||||
// the limit should not be hit.
|
||||
return nil, false, fmt.Errorf("unable to allocate additional ports on ProxyGroup %s, %d ports already used. Create another ProxyGroup or open an issue if you believe this is unexpected.", proxyGroupName, maxPorts)
|
||||
}
|
||||
p := unusedPort(usedPorts)
|
||||
l.Debugf("mapping tailnet target port %d to container port %d", wantsPM.Port, p)
|
||||
usedPorts.Insert(p)
|
||||
clusterIPSvc.Spec.Ports = append(clusterIPSvc.Spec.Ports, corev1.ServicePort{
|
||||
Name: wantsPM.Name,
|
||||
Protocol: wantsPM.Protocol,
|
||||
Port: wantsPM.Port,
|
||||
TargetPort: intstr.FromInt32(p),
|
||||
})
|
||||
}
|
||||
}
|
||||
if !reflect.DeepEqual(clusterIPSvc, oldClusterIPSvc) {
|
||||
if clusterIPSvc, err = createOrUpdate(ctx, esr.Client, esr.tsNamespace, clusterIPSvc, func(svc *corev1.Service) {
|
||||
svc.Labels = clusterIPSvc.Labels
|
||||
svc.Spec = clusterIPSvc.Spec
|
||||
}); err != nil {
|
||||
return nil, false, fmt.Errorf("error ensuring ClusterIP Service: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
crl := egressSvcEpsLabels(svc, clusterIPSvc)
|
||||
// TODO(irbekrm): support IPv6, but need to investigate how kube proxy
|
||||
// sets up Service -> Pod routing when IPv6 is involved.
|
||||
eps := &discoveryv1.EndpointSlice{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: fmt.Sprintf("%s-ipv4", clusterIPSvc.Name),
|
||||
Namespace: esr.tsNamespace,
|
||||
Labels: crl,
|
||||
},
|
||||
AddressType: discoveryv1.AddressTypeIPv4,
|
||||
Ports: epsPortsFromSvc(clusterIPSvc),
|
||||
}
|
||||
if eps, err = createOrUpdate(ctx, esr.Client, esr.tsNamespace, eps, func(e *discoveryv1.EndpointSlice) {
|
||||
e.Labels = eps.Labels
|
||||
e.AddressType = eps.AddressType
|
||||
e.Ports = eps.Ports
|
||||
for _, p := range e.Endpoints {
|
||||
p.Conditions.Ready = nil
|
||||
}
|
||||
}); err != nil {
|
||||
return nil, false, fmt.Errorf("error ensuring EndpointSlice: %w", err)
|
||||
}
|
||||
|
||||
cm, cfgs, err := egressSvcsConfigs(ctx, esr.Client, proxyGroupName, esr.tsNamespace)
|
||||
if err != nil {
|
||||
return nil, false, fmt.Errorf("error retrieving egress services configuration: %w", err)
|
||||
}
|
||||
if cm == nil {
|
||||
l.Info("ConfigMap not yet created, waiting..")
|
||||
return nil, false, nil
|
||||
}
|
||||
tailnetSvc := tailnetSvcName(svc)
|
||||
gotCfg := (*cfgs)[tailnetSvc]
|
||||
wantsCfg := egressSvcCfg(svc, clusterIPSvc)
|
||||
if !reflect.DeepEqual(gotCfg, wantsCfg) {
|
||||
l.Debugf("updating egress services ConfigMap %s", cm.Name)
|
||||
mak.Set(cfgs, tailnetSvc, wantsCfg)
|
||||
bs, err := json.Marshal(cfgs)
|
||||
if err != nil {
|
||||
return nil, false, fmt.Errorf("error marshalling egress services configs: %w", err)
|
||||
}
|
||||
mak.Set(&cm.BinaryData, egressservices.KeyEgressServices, bs)
|
||||
if err := esr.Update(ctx, cm); err != nil {
|
||||
return nil, false, fmt.Errorf("error updating egress services ConfigMap: %w", err)
|
||||
}
|
||||
}
|
||||
l.Infof("egress service configuration has been updated")
|
||||
return clusterIPSvc, true, nil
|
||||
}
|
||||
|
||||
func (esr *egressSvcsReconciler) maybeCleanup(ctx context.Context, svc *corev1.Service, logger *zap.SugaredLogger) error {
|
||||
logger.Info("ensuring that resources created for egress service are deleted")
|
||||
|
||||
// Delete egress service config from the ConfigMap mounted by the proxies.
|
||||
if err := esr.ensureEgressSvcCfgDeleted(ctx, svc, logger); err != nil {
|
||||
return fmt.Errorf("error deleting egress service config: %w", err)
|
||||
}
|
||||
|
||||
// Delete the ClusterIP Service and EndpointSlice for the egress
|
||||
// service.
|
||||
types := []client.Object{
|
||||
&corev1.Service{},
|
||||
&discoveryv1.EndpointSlice{},
|
||||
}
|
||||
crl := egressSvcChildResourceLabels(svc)
|
||||
for _, typ := range types {
|
||||
if err := esr.DeleteAllOf(ctx, typ, client.InNamespace(esr.tsNamespace), client.MatchingLabels(crl)); err != nil {
|
||||
return fmt.Errorf("error deleting %s: %w", typ, err)
|
||||
}
|
||||
}
|
||||
|
||||
ix := slices.Index(svc.Finalizers, FinalizerName)
|
||||
if ix != -1 {
|
||||
logger.Debug("Removing Tailscale finalizer from Service")
|
||||
svc.Finalizers = append(svc.Finalizers[:ix], svc.Finalizers[ix+1:]...)
|
||||
if err := esr.Update(ctx, svc); err != nil {
|
||||
return fmt.Errorf("failed to remove finalizer: %w", err)
|
||||
}
|
||||
}
|
||||
esr.mu.Lock()
|
||||
esr.svcs.Remove(svc.UID)
|
||||
gaugeEgressServices.Set(int64(esr.svcs.Len()))
|
||||
esr.mu.Unlock()
|
||||
logger.Info("successfully cleaned up resources for egress Service")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (esr *egressSvcsReconciler) maybeCleanupProxyGroupConfig(ctx context.Context, svc *corev1.Service, l *zap.SugaredLogger) error {
|
||||
wantsProxyGroup := svc.Annotations[AnnotationProxyGroup]
|
||||
cond := tsoperator.GetServiceCondition(svc, tsapi.EgressSvcConfigured)
|
||||
if cond == nil {
|
||||
return nil
|
||||
}
|
||||
ss := strings.Split(cond.Reason, ":")
|
||||
if len(ss) < 3 {
|
||||
return nil
|
||||
}
|
||||
if strings.EqualFold(wantsProxyGroup, ss[2]) {
|
||||
return nil
|
||||
}
|
||||
esr.logger.Infof("egress Service configured on ProxyGroup %s, wants ProxyGroup %s, cleaning up...", ss[2], wantsProxyGroup)
|
||||
if err := esr.ensureEgressSvcCfgDeleted(ctx, svc, l); err != nil {
|
||||
return fmt.Errorf("error deleting egress service config: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// usedPortsForPG calculates the currently used match ports for ProxyGroup
|
||||
// containers. It does that by looking by retrieving all target ports of all
|
||||
// ClusterIP Services created for egress services exposed on this ProxyGroup's
|
||||
// proxies.
|
||||
// TODO(irbekrm): this is currently good enough because we only have a single worker and
|
||||
// because these Services are created by us, so we can always expect to get the
|
||||
// latest ClusterIP Services via the controller cache. It will not work as well
|
||||
// once we split into multiple workers- at that point we probably want to set
|
||||
// used ports on ProxyGroup's status.
|
||||
func (esr *egressSvcsReconciler) usedPortsForPG(ctx context.Context, pg string) (sets.Set[int32], error) {
|
||||
svcList := &corev1.ServiceList{}
|
||||
if err := esr.List(ctx, svcList, client.InNamespace(esr.tsNamespace), client.MatchingLabels(map[string]string{labelProxyGroup: pg})); err != nil {
|
||||
return nil, fmt.Errorf("error listing Services: %w", err)
|
||||
}
|
||||
usedPorts := sets.New[int32]()
|
||||
for _, s := range svcList.Items {
|
||||
for _, p := range s.Spec.Ports {
|
||||
usedPorts.Insert(p.TargetPort.IntVal)
|
||||
}
|
||||
}
|
||||
return usedPorts, nil
|
||||
}
|
||||
|
||||
// clusterIPSvcForEgress returns a template for the ClusterIP Service created
|
||||
// for an egress service exposed on ProxyGroup proxies. The ClusterIP Service
|
||||
// has no selector. Traffic sent to it will be routed to the endpoints defined
|
||||
// by an EndpointSlice created for this egress service.
|
||||
func (esr *egressSvcsReconciler) clusterIPSvcForEgress(crl map[string]string) *corev1.Service {
|
||||
return &corev1.Service{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
GenerateName: svcNameBase(crl[LabelParentName]),
|
||||
Namespace: esr.tsNamespace,
|
||||
Labels: crl,
|
||||
},
|
||||
Spec: corev1.ServiceSpec{
|
||||
Type: corev1.ServiceTypeClusterIP,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (esr *egressSvcsReconciler) ensureEgressSvcCfgDeleted(ctx context.Context, svc *corev1.Service, logger *zap.SugaredLogger) error {
|
||||
crl := egressSvcChildResourceLabels(svc)
|
||||
cmName := pgEgressCMName(crl[labelProxyGroup])
|
||||
cm := &corev1.ConfigMap{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: cmName,
|
||||
Namespace: esr.tsNamespace,
|
||||
},
|
||||
}
|
||||
l := logger.With("ConfigMap", client.ObjectKeyFromObject(cm))
|
||||
l.Debug("ensuring that egress service configuration is removed from proxy config")
|
||||
if err := esr.Get(ctx, client.ObjectKeyFromObject(cm), cm); apierrors.IsNotFound(err) {
|
||||
l.Debugf("ConfigMap not found")
|
||||
return nil
|
||||
} else if err != nil {
|
||||
return fmt.Errorf("error retrieving ConfigMap: %w", err)
|
||||
}
|
||||
bs := cm.BinaryData[egressservices.KeyEgressServices]
|
||||
if len(bs) == 0 {
|
||||
l.Debugf("ConfigMap does not contain egress service configs")
|
||||
return nil
|
||||
}
|
||||
cfgs := &egressservices.Configs{}
|
||||
if err := json.Unmarshal(bs, cfgs); err != nil {
|
||||
return fmt.Errorf("error unmarshalling egress services configs")
|
||||
}
|
||||
tailnetSvc := tailnetSvcName(svc)
|
||||
_, ok := (*cfgs)[tailnetSvc]
|
||||
if !ok {
|
||||
l.Debugf("ConfigMap does not contain egress service config, likely because it was already deleted")
|
||||
return nil
|
||||
}
|
||||
l.Infof("before deleting config %+#v", *cfgs)
|
||||
delete(*cfgs, tailnetSvc)
|
||||
l.Infof("after deleting config %+#v", *cfgs)
|
||||
bs, err := json.Marshal(cfgs)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error marshalling egress services configs: %w", err)
|
||||
}
|
||||
mak.Set(&cm.BinaryData, egressservices.KeyEgressServices, bs)
|
||||
return esr.Update(ctx, cm)
|
||||
}
|
||||
|
||||
func (esr *egressSvcsReconciler) validateClusterResources(ctx context.Context, svc *corev1.Service, l *zap.SugaredLogger) (bool, error) {
|
||||
proxyGroupName := svc.Annotations[AnnotationProxyGroup]
|
||||
pg := &tsapi.ProxyGroup{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: proxyGroupName,
|
||||
},
|
||||
}
|
||||
if err := esr.Get(ctx, client.ObjectKeyFromObject(pg), pg); apierrors.IsNotFound(err) {
|
||||
l.Infof("ProxyGroup %q not found, waiting...", proxyGroupName)
|
||||
tsoperator.SetServiceCondition(svc, tsapi.EgressSvcValid, metav1.ConditionUnknown, reasonProxyGroupNotReady, reasonProxyGroupNotReady, esr.clock, l)
|
||||
tsoperator.RemoveServiceCondition(svc, tsapi.EgressSvcConfigured)
|
||||
return false, nil
|
||||
} else if err != nil {
|
||||
err := fmt.Errorf("unable to retrieve ProxyGroup %s: %w", proxyGroupName, err)
|
||||
tsoperator.SetServiceCondition(svc, tsapi.EgressSvcValid, metav1.ConditionUnknown, reasonProxyGroupNotReady, err.Error(), esr.clock, l)
|
||||
tsoperator.RemoveServiceCondition(svc, tsapi.EgressSvcConfigured)
|
||||
return false, err
|
||||
}
|
||||
if !tsoperator.ProxyGroupIsReady(pg) {
|
||||
l.Infof("ProxyGroup %s is not ready, waiting...", proxyGroupName)
|
||||
tsoperator.SetServiceCondition(svc, tsapi.EgressSvcValid, metav1.ConditionUnknown, reasonProxyGroupNotReady, reasonProxyGroupNotReady, esr.clock, l)
|
||||
tsoperator.RemoveServiceCondition(svc, tsapi.EgressSvcConfigured)
|
||||
return false, nil
|
||||
}
|
||||
|
||||
if violations := validateEgressService(svc, pg); len(violations) > 0 {
|
||||
msg := fmt.Sprintf("invalid egress Service: %s", strings.Join(violations, ", "))
|
||||
esr.recorder.Event(svc, corev1.EventTypeWarning, "INVALIDSERVICE", msg)
|
||||
l.Info(msg)
|
||||
tsoperator.SetServiceCondition(svc, tsapi.EgressSvcValid, metav1.ConditionFalse, reasonEgressSvcInvalid, msg, esr.clock, l)
|
||||
tsoperator.RemoveServiceCondition(svc, tsapi.EgressSvcConfigured)
|
||||
return false, nil
|
||||
}
|
||||
l.Debugf("egress service is valid")
|
||||
tsoperator.SetServiceCondition(svc, tsapi.EgressSvcValid, metav1.ConditionTrue, reasonEgressSvcValid, reasonEgressSvcValid, esr.clock, l)
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func validateEgressService(svc *corev1.Service, pg *tsapi.ProxyGroup) []string {
|
||||
violations := validateService(svc)
|
||||
|
||||
// We check that only one of these two is set in the earlier validateService function.
|
||||
if svc.Annotations[AnnotationTailnetTargetFQDN] == "" && svc.Annotations[AnnotationTailnetTargetIP] == "" {
|
||||
violations = append(violations, fmt.Sprintf("egress Service for ProxyGroup must have one of %s, %s annotations set", AnnotationTailnetTargetFQDN, AnnotationTailnetTargetIP))
|
||||
}
|
||||
if len(svc.Spec.Ports) == 0 {
|
||||
violations = append(violations, "egress Service for ProxyGroup must have at least one target Port specified")
|
||||
}
|
||||
if svc.Spec.Type != corev1.ServiceTypeExternalName {
|
||||
violations = append(violations, fmt.Sprintf("unexpected egress Service type %s. The only supported type is ExternalName.", svc.Spec.Type))
|
||||
}
|
||||
if pg.Spec.Type != tsapi.ProxyGroupTypeEgress {
|
||||
violations = append(violations, fmt.Sprintf("egress Service references ProxyGroup of type %s, must be type %s", pg.Spec.Type, tsapi.ProxyGroupTypeEgress))
|
||||
}
|
||||
return violations
|
||||
}
|
||||
|
||||
// egressSvcNameBase returns a name base that can be passed to
|
||||
// ObjectMeta.GenerateName to generate a name for the ClusterIP Service.
|
||||
// The generated name needs to be short enough so that it can later be used to
|
||||
// generate a valid Kubernetes resource name for the EndpointSlice in form
|
||||
// 'ipv4-|ipv6-<ClusterIP Service name>.
|
||||
// A valid Kubernetes resource name must not be longer than 253 chars.
|
||||
func svcNameBase(s string) string {
|
||||
// -ipv4 - ipv6
|
||||
const maxClusterIPSvcNameLength = 253 - 5
|
||||
base := fmt.Sprintf("ts-%s-", s)
|
||||
generator := names.SimpleNameGenerator
|
||||
for {
|
||||
generatedName := generator.GenerateName(base)
|
||||
excess := len(generatedName) - maxClusterIPSvcNameLength
|
||||
if excess <= 0 {
|
||||
return base
|
||||
}
|
||||
base = base[:len(base)-1-excess] // cut off the excess chars
|
||||
base = base + "-" // re-instate the dash
|
||||
}
|
||||
}
|
||||
|
||||
// unusedPort returns a port in range [3000 - 4000). The caller must ensure that
|
||||
// usedPorts does not contain all ports in range [3000 - 4000).
|
||||
func unusedPort(usedPorts sets.Set[int32]) int32 {
|
||||
foundFreePort := false
|
||||
var suggestPort int32
|
||||
for !foundFreePort {
|
||||
suggestPort = rand.Int32N(maxPorts) + 3000
|
||||
if !usedPorts.Has(suggestPort) {
|
||||
foundFreePort = true
|
||||
}
|
||||
}
|
||||
return suggestPort
|
||||
}
|
||||
|
||||
// tailnetTargetFromSvc returns a tailnet target for the given egress Service.
|
||||
// Service must contain exactly one of tailscale.com/tailnet-ip,
|
||||
// tailscale.com/tailnet-fqdn annotations.
|
||||
func tailnetTargetFromSvc(svc *corev1.Service) egressservices.TailnetTarget {
|
||||
if fqdn := svc.Annotations[AnnotationTailnetTargetFQDN]; fqdn != "" {
|
||||
return egressservices.TailnetTarget{
|
||||
FQDN: fqdn,
|
||||
}
|
||||
}
|
||||
return egressservices.TailnetTarget{
|
||||
IP: svc.Annotations[AnnotationTailnetTargetIP],
|
||||
}
|
||||
}
|
||||
|
||||
func egressSvcCfg(externalNameSvc, clusterIPSvc *corev1.Service) egressservices.Config {
|
||||
tt := tailnetTargetFromSvc(externalNameSvc)
|
||||
cfg := egressservices.Config{TailnetTarget: tt}
|
||||
for _, svcPort := range clusterIPSvc.Spec.Ports {
|
||||
pm := portMap(svcPort)
|
||||
mak.Set(&cfg.Ports, pm, struct{}{})
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
func portMap(p corev1.ServicePort) egressservices.PortMap {
|
||||
// TODO (irbekrm): out of bounds check?
|
||||
return egressservices.PortMap{Protocol: string(p.Protocol), MatchPort: uint16(p.TargetPort.IntVal), TargetPort: uint16(p.Port)}
|
||||
}
|
||||
|
||||
func isEgressSvcForProxyGroup(obj client.Object) bool {
|
||||
s, ok := obj.(*corev1.Service)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
annots := s.ObjectMeta.Annotations
|
||||
return annots[AnnotationProxyGroup] != "" && (annots[AnnotationTailnetTargetFQDN] != "" || annots[AnnotationTailnetTargetIP] != "")
|
||||
}
|
||||
|
||||
// egressSvcConfig returns a ConfigMap that contains egress services configuration for the provided ProxyGroup as well
|
||||
// as unmarshalled configuration from the ConfigMap.
|
||||
func egressSvcsConfigs(ctx context.Context, cl client.Client, proxyGroupName, tsNamespace string) (cm *corev1.ConfigMap, cfgs *egressservices.Configs, err error) {
|
||||
name := pgEgressCMName(proxyGroupName)
|
||||
cm = &corev1.ConfigMap{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: name,
|
||||
Namespace: tsNamespace,
|
||||
},
|
||||
}
|
||||
if err := cl.Get(ctx, client.ObjectKeyFromObject(cm), cm); err != nil {
|
||||
return nil, nil, fmt.Errorf("error retrieving egress services ConfigMap %s: %v", name, err)
|
||||
}
|
||||
cfgs = &egressservices.Configs{}
|
||||
if len(cm.BinaryData[egressservices.KeyEgressServices]) != 0 {
|
||||
if err := json.Unmarshal(cm.BinaryData[egressservices.KeyEgressServices], cfgs); err != nil {
|
||||
return nil, nil, fmt.Errorf("error unmarshaling egress services config %v: %w", cm.BinaryData[egressservices.KeyEgressServices], err)
|
||||
}
|
||||
}
|
||||
return cm, cfgs, nil
|
||||
}
|
||||
|
||||
// egressSvcChildResourceLabels returns labels that should be applied to the
|
||||
// ClusterIP Service and the EndpointSlice created for the egress service.
|
||||
// TODO(irbekrm): we currently set a bunch of labels based on Kubernetes
|
||||
// resource names (ProxyGroup, Service). Maximum allowed label length is 63
|
||||
// chars whilst the maximum allowed resource name length is 253 chars, so we
|
||||
// should probably validate and truncate (?) the names is they are too long.
|
||||
func egressSvcChildResourceLabels(svc *corev1.Service) map[string]string {
|
||||
return map[string]string{
|
||||
LabelManaged: "true",
|
||||
LabelParentType: "svc",
|
||||
LabelParentName: svc.Name,
|
||||
LabelParentNamespace: svc.Namespace,
|
||||
labelProxyGroup: svc.Annotations[AnnotationProxyGroup],
|
||||
labelSvcType: typeEgress,
|
||||
}
|
||||
}
|
||||
|
||||
// egressEpsLabels returns labels to be added to an EndpointSlice created for an egress service.
|
||||
func egressSvcEpsLabels(extNSvc, clusterIPSvc *corev1.Service) map[string]string {
|
||||
l := egressSvcChildResourceLabels(extNSvc)
|
||||
// Adding this label is what makes kube proxy set up rules to route traffic sent to the clusterIP Service to the
|
||||
// endpoints defined on this EndpointSlice.
|
||||
// https://kubernetes.io/docs/concepts/services-networking/endpoint-slices/#ownership
|
||||
l[discoveryv1.LabelServiceName] = clusterIPSvc.Name
|
||||
// Kubernetes recommends setting this label.
|
||||
// https://kubernetes.io/docs/concepts/services-networking/endpoint-slices/#management
|
||||
l[discoveryv1.LabelManagedBy] = "tailscale.com"
|
||||
return l
|
||||
}
|
||||
|
||||
func svcConfigurationUpToDate(svc *corev1.Service, l *zap.SugaredLogger) bool {
|
||||
cond := tsoperator.GetServiceCondition(svc, tsapi.EgressSvcConfigured)
|
||||
if cond == nil {
|
||||
return false
|
||||
}
|
||||
if cond.Status != metav1.ConditionTrue {
|
||||
return false
|
||||
}
|
||||
wantsReadyReason := svcConfiguredReason(svc, true, l)
|
||||
return strings.EqualFold(wantsReadyReason, cond.Reason)
|
||||
}
|
||||
|
||||
func cfgHash(c cfg, l *zap.SugaredLogger) string {
|
||||
bs, err := json.Marshal(c)
|
||||
if err != nil {
|
||||
// Don't use l.Error as that messes up component logs with, in this case, unnecessary stack trace.
|
||||
l.Infof("error marhsalling Config: %v", err)
|
||||
return ""
|
||||
}
|
||||
h := sha256.New()
|
||||
if _, err := h.Write(bs); err != nil {
|
||||
// Don't use l.Error as that messes up component logs with, in this case, unnecessary stack trace.
|
||||
l.Infof("error producing Config hash: %v", err)
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("%x", h.Sum(nil))
|
||||
}
|
||||
|
||||
type cfg struct {
|
||||
Ports []corev1.ServicePort `json:"ports"`
|
||||
TailnetTarget egressservices.TailnetTarget `json:"tailnetTarget"`
|
||||
ProxyGroup string `json:"proxyGroup"`
|
||||
}
|
||||
|
||||
func svcConfiguredReason(svc *corev1.Service, configured bool, l *zap.SugaredLogger) string {
|
||||
var r string
|
||||
if configured {
|
||||
r = "ConfiguredFor:"
|
||||
} else {
|
||||
r = fmt.Sprintf("ConfigurationFailed:%s", r)
|
||||
}
|
||||
r += fmt.Sprintf("ProxyGroup:%s", svc.Annotations[AnnotationProxyGroup])
|
||||
tt := tailnetTargetFromSvc(svc)
|
||||
s := cfg{
|
||||
Ports: svc.Spec.Ports,
|
||||
TailnetTarget: tt,
|
||||
ProxyGroup: svc.Annotations[AnnotationProxyGroup],
|
||||
}
|
||||
r += fmt.Sprintf(":Config:%s", cfgHash(s, l))
|
||||
return r
|
||||
}
|
||||
|
||||
// tailnetSvc accepts and ExternalName Service name and returns a name that will be used to distinguish this tailnet
|
||||
// service from other tailnet services exposed to cluster workloads.
|
||||
func tailnetSvcName(extNSvc *corev1.Service) string {
|
||||
return fmt.Sprintf("%s-%s", extNSvc.Namespace, extNSvc.Name)
|
||||
}
|
||||
|
||||
// epsPortsFromSvc takes the ClusterIP Service created for an egress service and
|
||||
// returns its Port array in a form that can be used for an EndpointSlice.
|
||||
func epsPortsFromSvc(svc *corev1.Service) (ep []discoveryv1.EndpointPort) {
|
||||
for _, p := range svc.Spec.Ports {
|
||||
ep = append(ep, discoveryv1.EndpointPort{
|
||||
Protocol: &p.Protocol,
|
||||
Port: &p.TargetPort.IntVal,
|
||||
Name: &p.Name,
|
||||
})
|
||||
}
|
||||
return ep
|
||||
}
|
||||
268
cmd/k8s-operator/egress-services_test.go
Normal file
268
cmd/k8s-operator/egress-services_test.go
Normal file
@@ -0,0 +1,268 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
//go:build !plan9
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/AlekSi/pointer"
|
||||
"github.com/google/go-cmp/cmp"
|
||||
"go.uber.org/zap"
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
discoveryv1 "k8s.io/api/discovery/v1"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/types"
|
||||
"sigs.k8s.io/controller-runtime/pkg/client"
|
||||
"sigs.k8s.io/controller-runtime/pkg/client/fake"
|
||||
tsapi "tailscale.com/k8s-operator/apis/v1alpha1"
|
||||
"tailscale.com/kube/egressservices"
|
||||
"tailscale.com/tstest"
|
||||
"tailscale.com/tstime"
|
||||
)
|
||||
|
||||
func TestTailscaleEgressServices(t *testing.T) {
|
||||
pg := &tsapi.ProxyGroup{
|
||||
TypeMeta: metav1.TypeMeta{Kind: "ProxyGroup", APIVersion: "tailscale.com/v1alpha1"},
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "foo",
|
||||
UID: types.UID("1234-UID"),
|
||||
},
|
||||
Spec: tsapi.ProxyGroupSpec{
|
||||
Replicas: pointer.To[int32](3),
|
||||
Type: tsapi.ProxyGroupTypeEgress,
|
||||
},
|
||||
}
|
||||
cm := &corev1.ConfigMap{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: pgEgressCMName("foo"),
|
||||
Namespace: "operator-ns",
|
||||
},
|
||||
}
|
||||
fc := fake.NewClientBuilder().
|
||||
WithScheme(tsapi.GlobalScheme).
|
||||
WithObjects(pg, cm).
|
||||
WithStatusSubresource(pg).
|
||||
Build()
|
||||
zl, err := zap.NewDevelopment()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
clock := tstest.NewClock(tstest.ClockOpts{})
|
||||
|
||||
esr := &egressSvcsReconciler{
|
||||
Client: fc,
|
||||
logger: zl.Sugar(),
|
||||
clock: clock,
|
||||
tsNamespace: "operator-ns",
|
||||
}
|
||||
tailnetTargetFQDN := "foo.bar.ts.net."
|
||||
svc := &corev1.Service{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test",
|
||||
Namespace: "default",
|
||||
UID: types.UID("1234-UID"),
|
||||
Annotations: map[string]string{
|
||||
AnnotationTailnetTargetFQDN: tailnetTargetFQDN,
|
||||
AnnotationProxyGroup: "foo",
|
||||
},
|
||||
},
|
||||
Spec: corev1.ServiceSpec{
|
||||
ExternalName: "placeholder",
|
||||
Type: corev1.ServiceTypeExternalName,
|
||||
Selector: nil,
|
||||
Ports: []corev1.ServicePort{
|
||||
{
|
||||
Name: "http",
|
||||
Protocol: "TCP",
|
||||
Port: 80,
|
||||
},
|
||||
{
|
||||
Name: "https",
|
||||
Protocol: "TCP",
|
||||
Port: 443,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
t.Run("proxy_group_not_ready", func(t *testing.T) {
|
||||
mustCreate(t, fc, svc)
|
||||
expectReconciled(t, esr, "default", "test")
|
||||
// Service should have EgressSvcValid condition set to Unknown.
|
||||
svc.Status.Conditions = []metav1.Condition{condition(tsapi.EgressSvcValid, metav1.ConditionUnknown, reasonProxyGroupNotReady, reasonProxyGroupNotReady, clock)}
|
||||
expectEqual(t, fc, svc, nil)
|
||||
})
|
||||
|
||||
t.Run("proxy_group_ready", func(t *testing.T) {
|
||||
mustUpdateStatus(t, fc, "", "foo", func(pg *tsapi.ProxyGroup) {
|
||||
pg.Status.Conditions = []metav1.Condition{
|
||||
condition(tsapi.ProxyGroupReady, metav1.ConditionTrue, "", "", clock),
|
||||
}
|
||||
})
|
||||
// Quirks of the fake client.
|
||||
mustUpdateStatus(t, fc, "default", "test", func(svc *corev1.Service) {
|
||||
svc.Status.Conditions = []metav1.Condition{}
|
||||
})
|
||||
expectReconciled(t, esr, "default", "test")
|
||||
// Verify that a ClusterIP Service has been created.
|
||||
name := findGenNameForEgressSvcResources(t, fc, svc)
|
||||
expectEqual(t, fc, clusterIPSvc(name, svc), removeTargetPortsFromSvc)
|
||||
clusterSvc := mustGetClusterIPSvc(t, fc, name)
|
||||
// Verify that an EndpointSlice has been created.
|
||||
expectEqual(t, fc, endpointSlice(name, svc, clusterSvc), nil)
|
||||
// Verify that ConfigMap contains configuration for the new egress service.
|
||||
mustHaveConfigForSvc(t, fc, svc, clusterSvc, cm)
|
||||
r := svcConfiguredReason(svc, true, zl.Sugar())
|
||||
// Verify that the user-created ExternalName Service has Configured set to true and ExternalName pointing to the
|
||||
// CluterIP Service.
|
||||
svc.Status.Conditions = []metav1.Condition{
|
||||
condition(tsapi.EgressSvcConfigured, metav1.ConditionTrue, r, r, clock),
|
||||
}
|
||||
svc.ObjectMeta.Finalizers = []string{"tailscale.com/finalizer"}
|
||||
svc.Spec.ExternalName = fmt.Sprintf("%s.operator-ns.svc.cluster.local", name)
|
||||
expectEqual(t, fc, svc, nil)
|
||||
})
|
||||
|
||||
t.Run("delete_external_name_service", func(t *testing.T) {
|
||||
name := findGenNameForEgressSvcResources(t, fc, svc)
|
||||
if err := fc.Delete(context.Background(), svc); err != nil {
|
||||
t.Fatalf("error deleting ExternalName Service: %v", err)
|
||||
}
|
||||
expectReconciled(t, esr, "default", "test")
|
||||
// Verify that ClusterIP Service and EndpointSlice have been deleted.
|
||||
expectMissing[corev1.Service](t, fc, "operator-ns", name)
|
||||
expectMissing[discoveryv1.EndpointSlice](t, fc, "operator-ns", fmt.Sprintf("%s-ipv4", name))
|
||||
// Verify that service config has been deleted from the ConfigMap.
|
||||
mustNotHaveConfigForSvc(t, fc, svc, cm)
|
||||
})
|
||||
}
|
||||
|
||||
func condition(typ tsapi.ConditionType, st metav1.ConditionStatus, r, msg string, clock tstime.Clock) metav1.Condition {
|
||||
return metav1.Condition{
|
||||
Type: string(typ),
|
||||
Status: st,
|
||||
LastTransitionTime: conditionTime(clock),
|
||||
Reason: r,
|
||||
Message: msg,
|
||||
}
|
||||
}
|
||||
|
||||
func findGenNameForEgressSvcResources(t *testing.T, client client.Client, svc *corev1.Service) string {
|
||||
t.Helper()
|
||||
labels := egressSvcChildResourceLabels(svc)
|
||||
s, err := getSingleObject[corev1.Service](context.Background(), client, "operator-ns", labels)
|
||||
if err != nil {
|
||||
t.Fatalf("finding ClusterIP Service for ExternalName Service %s: %v", svc.Name, err)
|
||||
}
|
||||
if s == nil {
|
||||
t.Fatalf("no ClusterIP Service found for ExternalName Service %q", svc.Name)
|
||||
}
|
||||
return s.GetName()
|
||||
}
|
||||
|
||||
func clusterIPSvc(name string, extNSvc *corev1.Service) *corev1.Service {
|
||||
labels := egressSvcChildResourceLabels(extNSvc)
|
||||
return &corev1.Service{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: name,
|
||||
Namespace: "operator-ns",
|
||||
GenerateName: fmt.Sprintf("ts-%s-", extNSvc.Name),
|
||||
Labels: labels,
|
||||
},
|
||||
Spec: corev1.ServiceSpec{
|
||||
Type: corev1.ServiceTypeClusterIP,
|
||||
Ports: extNSvc.Spec.Ports,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func mustGetClusterIPSvc(t *testing.T, cl client.Client, name string) *corev1.Service {
|
||||
svc := &corev1.Service{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: name,
|
||||
Namespace: "operator-ns",
|
||||
},
|
||||
}
|
||||
if err := cl.Get(context.Background(), client.ObjectKeyFromObject(svc), svc); err != nil {
|
||||
t.Fatalf("error retrieving Service")
|
||||
}
|
||||
return svc
|
||||
}
|
||||
|
||||
func endpointSlice(name string, extNSvc, clusterIPSvc *corev1.Service) *discoveryv1.EndpointSlice {
|
||||
labels := egressSvcChildResourceLabels(extNSvc)
|
||||
labels[discoveryv1.LabelManagedBy] = "tailscale.com"
|
||||
labels[discoveryv1.LabelServiceName] = name
|
||||
return &discoveryv1.EndpointSlice{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: fmt.Sprintf("%s-ipv4", name),
|
||||
Namespace: "operator-ns",
|
||||
Labels: labels,
|
||||
},
|
||||
Ports: portsForEndpointSlice(clusterIPSvc),
|
||||
AddressType: discoveryv1.AddressTypeIPv4,
|
||||
}
|
||||
}
|
||||
|
||||
func portsForEndpointSlice(svc *corev1.Service) []discoveryv1.EndpointPort {
|
||||
ports := make([]discoveryv1.EndpointPort, 0)
|
||||
for _, p := range svc.Spec.Ports {
|
||||
ports = append(ports, discoveryv1.EndpointPort{
|
||||
Name: &p.Name,
|
||||
Protocol: &p.Protocol,
|
||||
Port: pointer.ToInt32(p.TargetPort.IntVal),
|
||||
})
|
||||
}
|
||||
return ports
|
||||
}
|
||||
|
||||
func mustHaveConfigForSvc(t *testing.T, cl client.Client, extNSvc, clusterIPSvc *corev1.Service, cm *corev1.ConfigMap) {
|
||||
t.Helper()
|
||||
wantsCfg := egressSvcCfg(extNSvc, clusterIPSvc)
|
||||
if err := cl.Get(context.Background(), client.ObjectKeyFromObject(cm), cm); err != nil {
|
||||
t.Fatalf("Error retrieving ConfigMap: %v", err)
|
||||
}
|
||||
name := tailnetSvcName(extNSvc)
|
||||
gotCfg := configFromCM(t, cm, name)
|
||||
if gotCfg == nil {
|
||||
t.Fatalf("No config found for service %q", name)
|
||||
}
|
||||
if diff := cmp.Diff(*gotCfg, wantsCfg); diff != "" {
|
||||
t.Fatalf("unexpected config for service %q (-got +want):\n%s", name, diff)
|
||||
}
|
||||
}
|
||||
|
||||
func mustNotHaveConfigForSvc(t *testing.T, cl client.Client, extNSvc *corev1.Service, cm *corev1.ConfigMap) {
|
||||
t.Helper()
|
||||
if err := cl.Get(context.Background(), client.ObjectKeyFromObject(cm), cm); err != nil {
|
||||
t.Fatalf("Error retrieving ConfigMap: %v", err)
|
||||
}
|
||||
name := tailnetSvcName(extNSvc)
|
||||
gotCfg := configFromCM(t, cm, name)
|
||||
if gotCfg != nil {
|
||||
t.Fatalf("Config %#+v for service %q found when it should not be present", gotCfg, name)
|
||||
}
|
||||
}
|
||||
|
||||
func configFromCM(t *testing.T, cm *corev1.ConfigMap, svcName string) *egressservices.Config {
|
||||
t.Helper()
|
||||
cfgBs, ok := cm.BinaryData[egressservices.KeyEgressServices]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
cfgs := &egressservices.Configs{}
|
||||
if err := json.Unmarshal(cfgBs, cfgs); err != nil {
|
||||
t.Fatalf("error unmarshalling config: %v", err)
|
||||
}
|
||||
cfg, ok := (*cfgs)[svcName]
|
||||
if ok {
|
||||
return &cfg
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
|
||||
//go:build !plan9
|
||||
|
||||
// The generate command creates tailscale.com CRDs.
|
||||
package main
|
||||
|
||||
import (
|
||||
@@ -23,10 +24,14 @@ const (
|
||||
connectorCRDPath = operatorDeploymentFilesPath + "/crds/tailscale.com_connectors.yaml"
|
||||
proxyClassCRDPath = operatorDeploymentFilesPath + "/crds/tailscale.com_proxyclasses.yaml"
|
||||
dnsConfigCRDPath = operatorDeploymentFilesPath + "/crds/tailscale.com_dnsconfigs.yaml"
|
||||
recorderCRDPath = operatorDeploymentFilesPath + "/crds/tailscale.com_recorders.yaml"
|
||||
proxyGroupCRDPath = operatorDeploymentFilesPath + "/crds/tailscale.com_proxygroups.yaml"
|
||||
helmTemplatesPath = operatorDeploymentFilesPath + "/chart/templates"
|
||||
connectorCRDHelmTemplatePath = helmTemplatesPath + "/connector.yaml"
|
||||
proxyClassCRDHelmTemplatePath = helmTemplatesPath + "/proxyclass.yaml"
|
||||
dnsConfigCRDHelmTemplatePath = helmTemplatesPath + "/dnsconfig.yaml"
|
||||
recorderCRDHelmTemplatePath = helmTemplatesPath + "/recorder.yaml"
|
||||
proxyGroupCRDHelmTemplatePath = helmTemplatesPath + "/proxygroup.yaml"
|
||||
|
||||
helmConditionalStart = "{{ if .Values.installCRDs -}}\n"
|
||||
helmConditionalEnd = "{{- end -}}"
|
||||
@@ -110,7 +115,7 @@ func main() {
|
||||
}
|
||||
}
|
||||
|
||||
// generate places tailscale.com CRDs (currently Connector, ProxyClass and DNSConfig) into
|
||||
// generate places tailscale.com CRDs (currently Connector, ProxyClass, DNSConfig, Recorder) into
|
||||
// the Helm chart templates behind .Values.installCRDs=true condition (true by
|
||||
// default).
|
||||
func generate(baseDir string) error {
|
||||
@@ -136,28 +141,34 @@ func generate(baseDir string) error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if err := addCRDToHelm(connectorCRDPath, connectorCRDHelmTemplatePath); err != nil {
|
||||
return fmt.Errorf("error adding Connector CRD to Helm templates: %w", err)
|
||||
}
|
||||
if err := addCRDToHelm(proxyClassCRDPath, proxyClassCRDHelmTemplatePath); err != nil {
|
||||
return fmt.Errorf("error adding ProxyClass CRD to Helm templates: %w", err)
|
||||
}
|
||||
if err := addCRDToHelm(dnsConfigCRDPath, dnsConfigCRDHelmTemplatePath); err != nil {
|
||||
return fmt.Errorf("error adding DNSConfig CRD to Helm templates: %w", err)
|
||||
for _, crd := range []struct {
|
||||
crdPath, templatePath string
|
||||
}{
|
||||
{connectorCRDPath, connectorCRDHelmTemplatePath},
|
||||
{proxyClassCRDPath, proxyClassCRDHelmTemplatePath},
|
||||
{dnsConfigCRDPath, dnsConfigCRDHelmTemplatePath},
|
||||
{recorderCRDPath, recorderCRDHelmTemplatePath},
|
||||
{proxyGroupCRDPath, proxyGroupCRDHelmTemplatePath},
|
||||
} {
|
||||
if err := addCRDToHelm(crd.crdPath, crd.templatePath); err != nil {
|
||||
return fmt.Errorf("error adding %s CRD to Helm templates: %w", crd.crdPath, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func cleanup(baseDir string) error {
|
||||
log.Print("Cleaning up CRD from Helm templates")
|
||||
if err := os.Remove(filepath.Join(baseDir, connectorCRDHelmTemplatePath)); err != nil && !os.IsNotExist(err) {
|
||||
return fmt.Errorf("error cleaning up Connector CRD template: %w", err)
|
||||
}
|
||||
if err := os.Remove(filepath.Join(baseDir, proxyClassCRDHelmTemplatePath)); err != nil && !os.IsNotExist(err) {
|
||||
return fmt.Errorf("error cleaning up ProxyClass CRD template: %w", err)
|
||||
}
|
||||
if err := os.Remove(filepath.Join(baseDir, dnsConfigCRDHelmTemplatePath)); err != nil && !os.IsNotExist(err) {
|
||||
return fmt.Errorf("error cleaning up DNSConfig CRD template: %w", err)
|
||||
for _, path := range []string{
|
||||
connectorCRDHelmTemplatePath,
|
||||
proxyClassCRDHelmTemplatePath,
|
||||
dnsConfigCRDHelmTemplatePath,
|
||||
recorderCRDHelmTemplatePath,
|
||||
proxyGroupCRDHelmTemplatePath,
|
||||
} {
|
||||
if err := os.Remove(filepath.Join(baseDir, path)); err != nil && !os.IsNotExist(err) {
|
||||
return fmt.Errorf("error cleaning up %s: %w", path, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -59,6 +59,12 @@ func Test_generate(t *testing.T) {
|
||||
if !strings.Contains(installContentsWithCRD.String(), "name: dnsconfigs.tailscale.com") {
|
||||
t.Errorf("DNSConfig CRD not found in default chart install")
|
||||
}
|
||||
if !strings.Contains(installContentsWithCRD.String(), "name: recorders.tailscale.com") {
|
||||
t.Errorf("Recorder CRD not found in default chart install")
|
||||
}
|
||||
if !strings.Contains(installContentsWithCRD.String(), "name: proxygroups.tailscale.com") {
|
||||
t.Errorf("ProxyGroup CRD not found in default chart install")
|
||||
}
|
||||
|
||||
// Test that CRDs can be excluded from Helm chart install
|
||||
installContentsWithoutCRD := bytes.NewBuffer([]byte{})
|
||||
@@ -77,4 +83,10 @@ func Test_generate(t *testing.T) {
|
||||
if strings.Contains(installContentsWithoutCRD.String(), "name: dnsconfigs.tailscale.com") {
|
||||
t.Errorf("DNSConfig CRD found in chart install that should not contain a CRD")
|
||||
}
|
||||
if strings.Contains(installContentsWithoutCRD.String(), "name: recorders.tailscale.com") {
|
||||
t.Errorf("Recorder CRD found in chart install that should not contain a CRD")
|
||||
}
|
||||
if strings.Contains(installContentsWithoutCRD.String(), "name: proxygroups.tailscale.com") {
|
||||
t.Errorf("ProxyGroup CRD found in chart install that should not contain a CRD")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ import (
|
||||
"sigs.k8s.io/controller-runtime/pkg/client"
|
||||
"sigs.k8s.io/controller-runtime/pkg/reconcile"
|
||||
"tailscale.com/ipn"
|
||||
"tailscale.com/kube/kubetypes"
|
||||
"tailscale.com/types/opt"
|
||||
"tailscale.com/util/clientmetric"
|
||||
"tailscale.com/util/set"
|
||||
@@ -46,12 +47,14 @@ type IngressReconciler struct {
|
||||
// managedIngresses is a set of all ingress resources that we're currently
|
||||
// managing. This is only used for metrics.
|
||||
managedIngresses set.Slice[types.UID]
|
||||
|
||||
defaultProxyClass string
|
||||
}
|
||||
|
||||
var (
|
||||
// gaugeIngressResources tracks the number of ingress resources that we're
|
||||
// currently managing.
|
||||
gaugeIngressResources = clientmetric.NewGauge("k8s_ingress_resources")
|
||||
gaugeIngressResources = clientmetric.NewGauge(kubetypes.MetricIngressResourceCount)
|
||||
)
|
||||
|
||||
func (a *IngressReconciler) Reconcile(ctx context.Context, req reconcile.Request) (_ reconcile.Result, err error) {
|
||||
@@ -133,7 +136,7 @@ func (a *IngressReconciler) maybeProvision(ctx context.Context, logger *zap.Suga
|
||||
}
|
||||
}
|
||||
|
||||
proxyClass := proxyClassForObject(ing)
|
||||
proxyClass := proxyClassForObject(ing, a.defaultProxyClass)
|
||||
if proxyClass != "" {
|
||||
if ready, err := proxyClassIsReady(ctx, proxyClass, a.Client); err != nil {
|
||||
return fmt.Errorf("error verifying ProxyClass for Ingress: %w", err)
|
||||
@@ -264,7 +267,7 @@ func (a *IngressReconciler) maybeProvision(ctx context.Context, logger *zap.Suga
|
||||
ServeConfig: sc,
|
||||
Tags: tags,
|
||||
ChildResourceLabels: crl,
|
||||
ProxyClass: proxyClass,
|
||||
ProxyClassName: proxyClass,
|
||||
}
|
||||
|
||||
if val := ing.GetAnnotations()[AnnotationExperimentalForwardClusterTrafficViaL7IngresProxy]; val == "true" {
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
"sigs.k8s.io/controller-runtime/pkg/client/fake"
|
||||
"tailscale.com/ipn"
|
||||
tsapi "tailscale.com/k8s-operator/apis/v1alpha1"
|
||||
"tailscale.com/kube/kubetypes"
|
||||
"tailscale.com/types/ptr"
|
||||
"tailscale.com/util/mak"
|
||||
)
|
||||
@@ -93,6 +94,7 @@ func TestTailscaleIngress(t *testing.T) {
|
||||
namespace: "default",
|
||||
parentType: "ingress",
|
||||
hostname: "default-test",
|
||||
app: kubetypes.AppIngressResource,
|
||||
}
|
||||
serveConfig := &ipn.ServeConfig{
|
||||
TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}},
|
||||
@@ -100,7 +102,7 @@ func TestTailscaleIngress(t *testing.T) {
|
||||
}
|
||||
opts.serveConfig = serveConfig
|
||||
|
||||
expectEqual(t, fc, expectedSecret(t, opts), nil)
|
||||
expectEqual(t, fc, expectedSecret(t, fc, opts), nil)
|
||||
expectEqual(t, fc, expectedHeadlessService(shortName, "ingress"), nil)
|
||||
expectEqual(t, fc, expectedSTSUserspace(t, fc, opts), removeHashAnnotation)
|
||||
|
||||
@@ -224,6 +226,7 @@ func TestTailscaleIngressWithProxyClass(t *testing.T) {
|
||||
namespace: "default",
|
||||
parentType: "ingress",
|
||||
hostname: "default-test",
|
||||
app: kubetypes.AppIngressResource,
|
||||
}
|
||||
serveConfig := &ipn.ServeConfig{
|
||||
TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}},
|
||||
@@ -231,7 +234,7 @@ func TestTailscaleIngressWithProxyClass(t *testing.T) {
|
||||
}
|
||||
opts.serveConfig = serveConfig
|
||||
|
||||
expectEqual(t, fc, expectedSecret(t, opts), nil)
|
||||
expectEqual(t, fc, expectedSecret(t, fc, opts), nil)
|
||||
expectEqual(t, fc, expectedHeadlessService(shortName, "ingress"), nil)
|
||||
expectEqual(t, fc, expectedSTSUserspace(t, fc, opts), removeHashAnnotation)
|
||||
|
||||
@@ -248,9 +251,9 @@ func TestTailscaleIngressWithProxyClass(t *testing.T) {
|
||||
// created proxy resources.
|
||||
mustUpdateStatus(t, fc, "", "custom-metadata", func(pc *tsapi.ProxyClass) {
|
||||
pc.Status = tsapi.ProxyClassStatus{
|
||||
Conditions: []tsapi.ConnectorCondition{{
|
||||
Conditions: []metav1.Condition{{
|
||||
Status: metav1.ConditionTrue,
|
||||
Type: tsapi.ProxyClassready,
|
||||
Type: string(tsapi.ProxyClassReady),
|
||||
ObservedGeneration: pc.Generation,
|
||||
}}}
|
||||
})
|
||||
|
||||
@@ -28,6 +28,7 @@ import (
|
||||
"sigs.k8s.io/yaml"
|
||||
tsoperator "tailscale.com/k8s-operator"
|
||||
tsapi "tailscale.com/k8s-operator/apis/v1alpha1"
|
||||
"tailscale.com/kube/kubetypes"
|
||||
"tailscale.com/tstime"
|
||||
"tailscale.com/util/clientmetric"
|
||||
"tailscale.com/util/set"
|
||||
@@ -62,9 +63,7 @@ type NameserverReconciler struct {
|
||||
managedNameservers set.Slice[types.UID] // one or none
|
||||
}
|
||||
|
||||
var (
|
||||
gaugeNameserverResources = clientmetric.NewGauge("k8s_nameserver_resources")
|
||||
)
|
||||
var gaugeNameserverResources = clientmetric.NewGauge(kubetypes.MetricNameserverCount)
|
||||
|
||||
func (a *NameserverReconciler) Reconcile(ctx context.Context, req reconcile.Request) (res reconcile.Result, err error) {
|
||||
logger := a.logger.With("dnsConfig", req.Name)
|
||||
@@ -101,7 +100,7 @@ func (a *NameserverReconciler) Reconcile(ctx context.Context, req reconcile.Requ
|
||||
}
|
||||
|
||||
oldCnStatus := dnsCfg.Status.DeepCopy()
|
||||
setStatus := func(dnsCfg *tsapi.DNSConfig, conditionType tsapi.ConnectorConditionType, status metav1.ConditionStatus, reason, message string) (reconcile.Result, error) {
|
||||
setStatus := func(dnsCfg *tsapi.DNSConfig, conditionType tsapi.ConditionType, status metav1.ConditionStatus, reason, message string) (reconcile.Result, error) {
|
||||
tsoperator.SetDNSConfigCondition(dnsCfg, tsapi.NameserverReady, status, reason, message, dnsCfg.Generation, a.clock, logger)
|
||||
if !apiequality.Semantic.DeepEqual(oldCnStatus, dnsCfg.Status) {
|
||||
// An error encountered here should get returned by the Reconcile function.
|
||||
|
||||
@@ -33,7 +33,7 @@ func TestNameserverReconciler(t *testing.T) {
|
||||
},
|
||||
Spec: tsapi.DNSConfigSpec{
|
||||
Nameserver: &tsapi.Nameserver{
|
||||
Image: &tsapi.Image{
|
||||
Image: &tsapi.NameserverImage{
|
||||
Repo: "test",
|
||||
Tag: "v0.0.1",
|
||||
},
|
||||
@@ -81,12 +81,12 @@ func TestNameserverReconciler(t *testing.T) {
|
||||
IP: "1.2.3.4",
|
||||
}
|
||||
dnsCfg.Finalizers = []string{FinalizerName}
|
||||
dnsCfg.Status.Conditions = append(dnsCfg.Status.Conditions, tsapi.ConnectorCondition{
|
||||
Type: tsapi.NameserverReady,
|
||||
dnsCfg.Status.Conditions = append(dnsCfg.Status.Conditions, metav1.Condition{
|
||||
Type: string(tsapi.NameserverReady),
|
||||
Status: metav1.ConditionTrue,
|
||||
Reason: reasonNameserverCreated,
|
||||
Message: reasonNameserverCreated,
|
||||
LastTransitionTime: &metav1.Time{Time: cl.Now().Truncate(time.Second)},
|
||||
LastTransitionTime: metav1.Time{Time: cl.Now().Truncate(time.Second)},
|
||||
})
|
||||
expectEqual(t, fc, dnsCfg, nil)
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"context"
|
||||
"os"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -22,6 +23,7 @@ import (
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
discoveryv1 "k8s.io/api/discovery/v1"
|
||||
networkingv1 "k8s.io/api/networking/v1"
|
||||
rbacv1 "k8s.io/api/rbac/v1"
|
||||
"k8s.io/apimachinery/pkg/types"
|
||||
"k8s.io/client-go/rest"
|
||||
"sigs.k8s.io/controller-runtime/pkg/builder"
|
||||
@@ -39,6 +41,7 @@ import (
|
||||
"tailscale.com/ipn"
|
||||
"tailscale.com/ipn/store/kubestore"
|
||||
tsapi "tailscale.com/k8s-operator/apis/v1alpha1"
|
||||
"tailscale.com/kube/kubetypes"
|
||||
"tailscale.com/tsnet"
|
||||
"tailscale.com/tstime"
|
||||
"tailscale.com/types/logger"
|
||||
@@ -51,8 +54,8 @@ import (
|
||||
// Generate static manifests for deploying Tailscale operator on Kubernetes from the operator's Helm chart.
|
||||
//go:generate go run tailscale.com/cmd/k8s-operator/generate staticmanifests
|
||||
|
||||
// Generate CRD docs from the yamls
|
||||
//go:generate go run fybrik.io/crdoc --resources=./deploy/crds --output=../../k8s-operator/api.md
|
||||
// Generate CRD API docs.
|
||||
//go:generate go run github.com/elastic/crd-ref-docs --renderer=markdown --source-path=../../k8s-operator/apis/ --config=../../k8s-operator/api-docs-config.yaml --output-path=../../k8s-operator/api.md
|
||||
|
||||
func main() {
|
||||
// Required to use our client API. We're fine with the instability since the
|
||||
@@ -66,6 +69,7 @@ func main() {
|
||||
priorityClassName = defaultEnv("PROXY_PRIORITY_CLASS_NAME", "")
|
||||
tags = defaultEnv("PROXY_TAGS", "tag:k8s")
|
||||
tsFirewallMode = defaultEnv("PROXY_FIREWALL_MODE", "")
|
||||
defaultProxyClass = defaultEnv("PROXY_DEFAULT_CLASS", "")
|
||||
isDefaultLoadBalancer = defaultBool("OPERATOR_DEFAULT_LOAD_BALANCER", false)
|
||||
)
|
||||
|
||||
@@ -86,9 +90,9 @@ func main() {
|
||||
// https://tailscale.com/kb/1236/kubernetes-operator/?q=kubernetes#accessing-the-kubernetes-control-plane-using-an-api-server-proxy.
|
||||
mode := parseAPIProxyMode()
|
||||
if mode == apiserverProxyModeDisabled {
|
||||
hostinfo.SetApp("k8s-operator")
|
||||
hostinfo.SetApp(kubetypes.AppOperator)
|
||||
} else {
|
||||
hostinfo.SetApp("k8s-operator-proxy")
|
||||
hostinfo.SetApp(kubetypes.AppAPIServerProxy)
|
||||
}
|
||||
|
||||
s, tsClient := initTSNet(zlog)
|
||||
@@ -106,6 +110,7 @@ func main() {
|
||||
proxyActAsDefaultLoadBalancer: isDefaultLoadBalancer,
|
||||
proxyTags: tags,
|
||||
proxyFirewallMode: tsFirewallMode,
|
||||
defaultProxyClass: defaultProxyClass,
|
||||
}
|
||||
runReconcilers(rOpts)
|
||||
}
|
||||
@@ -139,12 +144,20 @@ func initTSNet(zlog *zap.SugaredLogger) (*tsnet.Server, *tailscale.Client) {
|
||||
TokenURL: "https://login.tailscale.com/api/v2/oauth/token",
|
||||
}
|
||||
tsClient := tailscale.NewClient("-", nil)
|
||||
tsClient.UserAgent = "tailscale-k8s-operator"
|
||||
tsClient.HTTPClient = credentials.Client(context.Background())
|
||||
|
||||
s := &tsnet.Server{
|
||||
Hostname: hostname,
|
||||
Logf: zlog.Named("tailscaled").Debugf,
|
||||
}
|
||||
if p := os.Getenv("TS_PORT"); p != "" {
|
||||
port, err := strconv.ParseUint(p, 10, 16)
|
||||
if err != nil {
|
||||
startlog.Fatalf("TS_PORT %q cannot be parsed as uint16: %v", p, err)
|
||||
}
|
||||
s.Port = uint16(port)
|
||||
}
|
||||
if kubeSecret != "" {
|
||||
st, err := kubestore.New(logger.Discard, kubeSecret)
|
||||
if err != nil {
|
||||
@@ -234,10 +247,13 @@ func runReconcilers(opts reconcilerOpts) {
|
||||
ByObject: map[client.Object]cache.ByObject{
|
||||
&corev1.Secret{}: nsFilter,
|
||||
&corev1.ServiceAccount{}: nsFilter,
|
||||
&corev1.Pod{}: nsFilter,
|
||||
&corev1.ConfigMap{}: nsFilter,
|
||||
&appsv1.StatefulSet{}: nsFilter,
|
||||
&appsv1.Deployment{}: nsFilter,
|
||||
&discoveryv1.EndpointSlice{}: nsFilter,
|
||||
&rbacv1.Role{}: nsFilter,
|
||||
&rbacv1.RoleBinding{}: nsFilter,
|
||||
},
|
||||
},
|
||||
Scheme: tsapi.GlobalScheme,
|
||||
@@ -278,6 +294,8 @@ func runReconcilers(opts reconcilerOpts) {
|
||||
isDefaultLoadBalancer: opts.proxyActAsDefaultLoadBalancer,
|
||||
recorder: eventRecorder,
|
||||
tsNamespace: opts.tailscaleNamespace,
|
||||
clock: tstime.DefaultClock{},
|
||||
defaultProxyClass: opts.defaultProxyClass,
|
||||
})
|
||||
if err != nil {
|
||||
startlog.Fatalf("could not create service reconciler: %v", err)
|
||||
@@ -296,10 +314,11 @@ func runReconcilers(opts reconcilerOpts) {
|
||||
Watches(&corev1.Service{}, svcHandlerForIngress).
|
||||
Watches(&tsapi.ProxyClass{}, proxyClassFilterForIngress).
|
||||
Complete(&IngressReconciler{
|
||||
ssr: ssr,
|
||||
recorder: eventRecorder,
|
||||
Client: mgr.GetClient(),
|
||||
logger: opts.log.Named("ingress-reconciler"),
|
||||
ssr: ssr,
|
||||
recorder: eventRecorder,
|
||||
Client: mgr.GetClient(),
|
||||
logger: opts.log.Named("ingress-reconciler"),
|
||||
defaultProxyClass: opts.defaultProxyClass,
|
||||
})
|
||||
if err != nil {
|
||||
startlog.Fatalf("could not create ingress reconciler: %v", err)
|
||||
@@ -344,6 +363,65 @@ func runReconcilers(opts reconcilerOpts) {
|
||||
if err != nil {
|
||||
startlog.Fatalf("could not create nameserver reconciler: %v", err)
|
||||
}
|
||||
|
||||
egressSvcFilter := handler.EnqueueRequestsFromMapFunc(egressSvcsHandler)
|
||||
egressProxyGroupFilter := handler.EnqueueRequestsFromMapFunc(egressSvcsFromEgressProxyGroup(mgr.GetClient(), opts.log))
|
||||
err = builder.
|
||||
ControllerManagedBy(mgr).
|
||||
Named("egress-svcs-reconciler").
|
||||
Watches(&corev1.Service{}, egressSvcFilter).
|
||||
Watches(&tsapi.ProxyGroup{}, egressProxyGroupFilter).
|
||||
Complete(&egressSvcsReconciler{
|
||||
Client: mgr.GetClient(),
|
||||
tsNamespace: opts.tailscaleNamespace,
|
||||
recorder: eventRecorder,
|
||||
clock: tstime.DefaultClock{},
|
||||
logger: opts.log.Named("egress-svcs-reconciler"),
|
||||
})
|
||||
if err != nil {
|
||||
startlog.Fatalf("could not create egress Services reconciler: %v", err)
|
||||
}
|
||||
if err := mgr.GetFieldIndexer().IndexField(context.Background(), new(corev1.Service), indexEgressProxyGroup, indexEgressServices); err != nil {
|
||||
startlog.Fatalf("failed setting up indexer for egress Services: %v", err)
|
||||
}
|
||||
|
||||
egressSvcFromEpsFilter := handler.EnqueueRequestsFromMapFunc(egressSvcFromEps)
|
||||
err = builder.
|
||||
ControllerManagedBy(mgr).
|
||||
Named("egress-svcs-readiness-reconciler").
|
||||
Watches(&corev1.Service{}, egressSvcFilter).
|
||||
Watches(&discoveryv1.EndpointSlice{}, egressSvcFromEpsFilter).
|
||||
Complete(&egressSvcsReadinessReconciler{
|
||||
Client: mgr.GetClient(),
|
||||
tsNamespace: opts.tailscaleNamespace,
|
||||
clock: tstime.DefaultClock{},
|
||||
logger: opts.log.Named("egress-svcs-readiness-reconciler"),
|
||||
})
|
||||
if err != nil {
|
||||
startlog.Fatalf("could not create egress Services readiness reconciler: %v", err)
|
||||
}
|
||||
|
||||
epsFilter := handler.EnqueueRequestsFromMapFunc(egressEpsHandler)
|
||||
podsFilter := handler.EnqueueRequestsFromMapFunc(egressEpsFromPGPods(mgr.GetClient(), opts.tailscaleNamespace))
|
||||
secretsFilter := handler.EnqueueRequestsFromMapFunc(egressEpsFromPGStateSecrets(mgr.GetClient(), opts.tailscaleNamespace))
|
||||
epsFromExtNSvcFilter := handler.EnqueueRequestsFromMapFunc(epsFromExternalNameService(mgr.GetClient(), opts.log, opts.tailscaleNamespace))
|
||||
|
||||
err = builder.
|
||||
ControllerManagedBy(mgr).
|
||||
Named("egress-eps-reconciler").
|
||||
Watches(&discoveryv1.EndpointSlice{}, epsFilter).
|
||||
Watches(&corev1.Pod{}, podsFilter).
|
||||
Watches(&corev1.Secret{}, secretsFilter).
|
||||
Watches(&corev1.Service{}, epsFromExtNSvcFilter).
|
||||
Complete(&egressEpsReconciler{
|
||||
Client: mgr.GetClient(),
|
||||
tsNamespace: opts.tailscaleNamespace,
|
||||
logger: opts.log.Named("egress-eps-reconciler"),
|
||||
})
|
||||
if err != nil {
|
||||
startlog.Fatalf("could not create egress EndpointSlices reconciler: %v", err)
|
||||
}
|
||||
|
||||
err = builder.ControllerManagedBy(mgr).
|
||||
For(&tsapi.ProxyClass{}).
|
||||
Complete(&ProxyClassReconciler{
|
||||
@@ -383,6 +461,56 @@ func runReconcilers(opts reconcilerOpts) {
|
||||
if err != nil {
|
||||
startlog.Fatalf("could not create DNS records reconciler: %v", err)
|
||||
}
|
||||
|
||||
// Recorder reconciler.
|
||||
recorderFilter := handler.EnqueueRequestForOwner(mgr.GetScheme(), mgr.GetRESTMapper(), &tsapi.Recorder{})
|
||||
err = builder.ControllerManagedBy(mgr).
|
||||
For(&tsapi.Recorder{}).
|
||||
Watches(&appsv1.StatefulSet{}, recorderFilter).
|
||||
Watches(&corev1.ServiceAccount{}, recorderFilter).
|
||||
Watches(&corev1.Secret{}, recorderFilter).
|
||||
Watches(&rbacv1.Role{}, recorderFilter).
|
||||
Watches(&rbacv1.RoleBinding{}, recorderFilter).
|
||||
Complete(&RecorderReconciler{
|
||||
recorder: eventRecorder,
|
||||
tsNamespace: opts.tailscaleNamespace,
|
||||
Client: mgr.GetClient(),
|
||||
l: opts.log.Named("recorder-reconciler"),
|
||||
clock: tstime.DefaultClock{},
|
||||
tsClient: opts.tsClient,
|
||||
})
|
||||
if err != nil {
|
||||
startlog.Fatalf("could not create Recorder reconciler: %v", err)
|
||||
}
|
||||
|
||||
// Recorder reconciler.
|
||||
ownedByProxyGroupFilter := handler.EnqueueRequestForOwner(mgr.GetScheme(), mgr.GetRESTMapper(), &tsapi.ProxyGroup{})
|
||||
proxyClassFilterForProxyGroup := handler.EnqueueRequestsFromMapFunc(proxyClassHandlerForProxyGroup(mgr.GetClient(), startlog))
|
||||
err = builder.ControllerManagedBy(mgr).
|
||||
For(&tsapi.ProxyGroup{}).
|
||||
Watches(&appsv1.StatefulSet{}, ownedByProxyGroupFilter).
|
||||
Watches(&corev1.ServiceAccount{}, ownedByProxyGroupFilter).
|
||||
Watches(&corev1.Secret{}, ownedByProxyGroupFilter).
|
||||
Watches(&rbacv1.Role{}, ownedByProxyGroupFilter).
|
||||
Watches(&rbacv1.RoleBinding{}, ownedByProxyGroupFilter).
|
||||
Watches(&tsapi.ProxyClass{}, proxyClassFilterForProxyGroup).
|
||||
Complete(&ProxyGroupReconciler{
|
||||
recorder: eventRecorder,
|
||||
Client: mgr.GetClient(),
|
||||
l: opts.log.Named("proxygroup-reconciler"),
|
||||
clock: tstime.DefaultClock{},
|
||||
tsClient: opts.tsClient,
|
||||
|
||||
tsNamespace: opts.tailscaleNamespace,
|
||||
proxyImage: opts.proxyImage,
|
||||
defaultTags: strings.Split(opts.proxyTags, ","),
|
||||
tsFirewallMode: opts.proxyFirewallMode,
|
||||
defaultProxyClass: opts.defaultProxyClass,
|
||||
})
|
||||
if err != nil {
|
||||
startlog.Fatalf("could not create ProxyGroup reconciler: %v", err)
|
||||
}
|
||||
|
||||
startlog.Infof("Startup complete, operator running, version: %s", version.Long())
|
||||
if err := mgr.Start(signals.SetupSignalHandler()); err != nil {
|
||||
startlog.Fatalf("could not start manager: %v", err)
|
||||
@@ -423,6 +551,10 @@ type reconcilerOpts struct {
|
||||
// Auto is usually the best choice, unless you want to explicitly set
|
||||
// specific mode for debugging purposes.
|
||||
proxyFirewallMode string
|
||||
// defaultProxyClass is the name of the ProxyClass to use as the default
|
||||
// class for proxies that do not have a ProxyClass set.
|
||||
// this is defined by an operator env variable.
|
||||
defaultProxyClass string
|
||||
}
|
||||
|
||||
// enqueueAllIngressEgressProxySvcsinNS returns a reconcile request for each
|
||||
@@ -515,6 +647,7 @@ func dnsRecordsReconcilerIngressHandler(ns string, isDefaultLoadBalancer bool, c
|
||||
|
||||
type tsClient interface {
|
||||
CreateKey(ctx context.Context, caps tailscale.KeyCapabilities) (string, *tailscale.Key, error)
|
||||
Device(ctx context.Context, deviceID string, fields *tailscale.DeviceFieldsOpts) (*tailscale.Device, error)
|
||||
DeleteDevice(ctx context.Context, nodeStableID string) error
|
||||
}
|
||||
|
||||
@@ -610,6 +743,27 @@ func proxyClassHandlerForConnector(cl client.Client, logger *zap.SugaredLogger)
|
||||
}
|
||||
}
|
||||
|
||||
// proxyClassHandlerForConnector returns a handler that, for a given ProxyClass,
|
||||
// returns a list of reconcile requests for all Connectors that have
|
||||
// .spec.proxyClass set.
|
||||
func proxyClassHandlerForProxyGroup(cl client.Client, logger *zap.SugaredLogger) handler.MapFunc {
|
||||
return func(ctx context.Context, o client.Object) []reconcile.Request {
|
||||
pgList := new(tsapi.ProxyGroupList)
|
||||
if err := cl.List(ctx, pgList); err != nil {
|
||||
logger.Debugf("error listing ProxyGroups for ProxyClass: %v", err)
|
||||
return nil
|
||||
}
|
||||
reqs := make([]reconcile.Request, 0)
|
||||
proxyClassName := o.GetName()
|
||||
for _, pg := range pgList.Items {
|
||||
if pg.Spec.ProxyClass == proxyClassName {
|
||||
reqs = append(reqs, reconcile.Request{NamespacedName: client.ObjectKeyFromObject(&pg)})
|
||||
}
|
||||
}
|
||||
return reqs
|
||||
}
|
||||
}
|
||||
|
||||
// serviceHandlerForIngress returns a handler for Service events for ingress
|
||||
// reconciler that ensures that if the Service associated with an event is of
|
||||
// interest to the reconciler, the associated Ingress(es) gets be reconciled.
|
||||
@@ -651,6 +805,10 @@ func serviceHandlerForIngress(cl client.Client, logger *zap.SugaredLogger) handl
|
||||
}
|
||||
|
||||
func serviceHandler(_ context.Context, o client.Object) []reconcile.Request {
|
||||
if _, ok := o.GetAnnotations()[AnnotationProxyGroup]; ok {
|
||||
// Do not reconcile Services for ProxyGroup.
|
||||
return nil
|
||||
}
|
||||
if isManagedByType(o, "svc") {
|
||||
// If this is a Service managed by a Service we want to enqueue its parent
|
||||
return []reconcile.Request{{NamespacedName: parentFromObjectLabels(o)}}
|
||||
@@ -676,3 +834,195 @@ func isMagicDNSName(name string) bool {
|
||||
validMagicDNSName := regexp.MustCompile(`^[a-zA-Z0-9-]+\.[a-zA-Z0-9-]+\.ts\.net\.?$`)
|
||||
return validMagicDNSName.MatchString(name)
|
||||
}
|
||||
|
||||
// egressSvcsHandler returns accepts a Kubernetes object and returns a reconcile
|
||||
// request for it , if the object is a Tailscale egress Service meant to be
|
||||
// exposed on a ProxyGroup.
|
||||
func egressSvcsHandler(_ context.Context, o client.Object) []reconcile.Request {
|
||||
if !isEgressSvcForProxyGroup(o) {
|
||||
return nil
|
||||
}
|
||||
return []reconcile.Request{
|
||||
{
|
||||
NamespacedName: types.NamespacedName{
|
||||
Namespace: o.GetNamespace(),
|
||||
Name: o.GetName(),
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// egressEpsHandler returns accepts an EndpointSlice and, if the EndpointSlice
|
||||
// is for an egress service, returns a reconcile request for it.
|
||||
func egressEpsHandler(_ context.Context, o client.Object) []reconcile.Request {
|
||||
if typ := o.GetLabels()[labelSvcType]; typ != typeEgress {
|
||||
return nil
|
||||
}
|
||||
return []reconcile.Request{
|
||||
{
|
||||
NamespacedName: types.NamespacedName{
|
||||
Namespace: o.GetNamespace(),
|
||||
Name: o.GetName(),
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// egressEpsFromEgressPods returns a Pod event handler that checks if Pod is a replica for a ProxyGroup and if it is,
|
||||
// returns reconciler requests for all egress EndpointSlices for that ProxyGroup.
|
||||
func egressEpsFromPGPods(cl client.Client, ns string) handler.MapFunc {
|
||||
return func(_ context.Context, o client.Object) []reconcile.Request {
|
||||
if v, ok := o.GetLabels()[LabelManaged]; !ok || v != "true" {
|
||||
return nil
|
||||
}
|
||||
// TODO(irbekrm): for now this is good enough as all ProxyGroups are egress. Add a type check once we
|
||||
// have ingress ProxyGroups.
|
||||
if typ := o.GetLabels()[LabelParentType]; typ != "proxygroup" {
|
||||
return nil
|
||||
}
|
||||
pg, ok := o.GetLabels()[LabelParentName]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return reconcileRequestsForPG(pg, cl, ns)
|
||||
}
|
||||
}
|
||||
|
||||
// egressEpsFromPGStateSecrets returns a Secret event handler that checks if Secret is a state Secret for a ProxyGroup and if it is,
|
||||
// returns reconciler requests for all egress EndpointSlices for that ProxyGroup.
|
||||
func egressEpsFromPGStateSecrets(cl client.Client, ns string) handler.MapFunc {
|
||||
return func(_ context.Context, o client.Object) []reconcile.Request {
|
||||
if v, ok := o.GetLabels()[LabelManaged]; !ok || v != "true" {
|
||||
return nil
|
||||
}
|
||||
// TODO(irbekrm): for now this is good enough as all ProxyGroups are egress. Add a type check once we
|
||||
// have ingress ProxyGroups.
|
||||
if parentType := o.GetLabels()[LabelParentType]; parentType != "proxygroup" {
|
||||
return nil
|
||||
}
|
||||
if secretType := o.GetLabels()[labelSecretType]; secretType != "state" {
|
||||
return nil
|
||||
}
|
||||
pg, ok := o.GetLabels()[LabelParentName]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return reconcileRequestsForPG(pg, cl, ns)
|
||||
}
|
||||
}
|
||||
|
||||
// egressSvcFromEps is an event handler for EndpointSlices. If an EndpointSlice is for an egress ExternalName Service
|
||||
// meant to be exposed on a ProxyGroup, returns a reconcile request for the Service.
|
||||
func egressSvcFromEps(_ context.Context, o client.Object) []reconcile.Request {
|
||||
if typ := o.GetLabels()[labelSvcType]; typ != typeEgress {
|
||||
return nil
|
||||
}
|
||||
if v, ok := o.GetLabels()[LabelManaged]; !ok || v != "true" {
|
||||
return nil
|
||||
}
|
||||
svcName, ok := o.GetLabels()[LabelParentName]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
svcNs, ok := o.GetLabels()[LabelParentNamespace]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return []reconcile.Request{
|
||||
{
|
||||
NamespacedName: types.NamespacedName{
|
||||
Namespace: svcNs,
|
||||
Name: svcName,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func reconcileRequestsForPG(pg string, cl client.Client, ns string) []reconcile.Request {
|
||||
epsList := discoveryv1.EndpointSliceList{}
|
||||
if err := cl.List(context.Background(), &epsList,
|
||||
client.InNamespace(ns),
|
||||
client.MatchingLabels(map[string]string{labelProxyGroup: pg})); err != nil {
|
||||
return nil
|
||||
}
|
||||
reqs := make([]reconcile.Request, 0)
|
||||
for _, ep := range epsList.Items {
|
||||
reqs = append(reqs, reconcile.Request{
|
||||
NamespacedName: types.NamespacedName{
|
||||
Namespace: ep.Namespace,
|
||||
Name: ep.Name,
|
||||
},
|
||||
})
|
||||
}
|
||||
return reqs
|
||||
}
|
||||
|
||||
// egressSvcsFromEgressProxyGroup is an event handler for egress ProxyGroups. It returns reconcile requests for all
|
||||
// user-created ExternalName Services that should be exposed on this ProxyGroup.
|
||||
func egressSvcsFromEgressProxyGroup(cl client.Client, logger *zap.SugaredLogger) handler.MapFunc {
|
||||
return func(_ context.Context, o client.Object) []reconcile.Request {
|
||||
pg, ok := o.(*tsapi.ProxyGroup)
|
||||
if !ok {
|
||||
logger.Infof("[unexpected] ProxyGroup handler triggered for an object that is not a ProxyGroup")
|
||||
return nil
|
||||
}
|
||||
if pg.Spec.Type != tsapi.ProxyGroupTypeEgress {
|
||||
return nil
|
||||
}
|
||||
svcList := &corev1.ServiceList{}
|
||||
if err := cl.List(context.Background(), svcList, client.MatchingFields{indexEgressProxyGroup: pg.Name}); err != nil {
|
||||
logger.Infof("error listing Services: %v, skipping a reconcile for event on ProxyGroup %s", err, pg.Name)
|
||||
return nil
|
||||
}
|
||||
reqs := make([]reconcile.Request, 0)
|
||||
for _, svc := range svcList.Items {
|
||||
reqs = append(reqs, reconcile.Request{
|
||||
NamespacedName: types.NamespacedName{
|
||||
Namespace: svc.Namespace,
|
||||
Name: svc.Name,
|
||||
},
|
||||
})
|
||||
}
|
||||
return reqs
|
||||
}
|
||||
}
|
||||
|
||||
// epsFromExternalNameService is an event handler for ExternalName Services that define a Tailscale egress service that
|
||||
// should be exposed on a ProxyGroup. It returns reconcile requests for EndpointSlices created for this Service.
|
||||
func epsFromExternalNameService(cl client.Client, logger *zap.SugaredLogger, ns string) handler.MapFunc {
|
||||
return func(_ context.Context, o client.Object) []reconcile.Request {
|
||||
svc, ok := o.(*corev1.Service)
|
||||
if !ok {
|
||||
logger.Infof("[unexpected] Service handler triggered for an object that is not a Service")
|
||||
return nil
|
||||
}
|
||||
if !isEgressSvcForProxyGroup(svc) {
|
||||
return nil
|
||||
}
|
||||
epsList := &discoveryv1.EndpointSliceList{}
|
||||
if err := cl.List(context.Background(), epsList, client.InNamespace(ns),
|
||||
client.MatchingLabels(egressSvcChildResourceLabels(svc))); err != nil {
|
||||
logger.Infof("error listing EndpointSlices: %v, skipping a reconcile for event on Service %s", err, svc.Name)
|
||||
return nil
|
||||
}
|
||||
reqs := make([]reconcile.Request, 0)
|
||||
for _, eps := range epsList.Items {
|
||||
reqs = append(reqs, reconcile.Request{
|
||||
NamespacedName: types.NamespacedName{
|
||||
Namespace: eps.Namespace,
|
||||
Name: eps.Name,
|
||||
},
|
||||
})
|
||||
}
|
||||
return reqs
|
||||
}
|
||||
}
|
||||
|
||||
// indexEgressServices adds a local index to a cached Tailscale egress Services meant to be exposed on a ProxyGroup. The
|
||||
// index is used a list filter.
|
||||
func indexEgressServices(o client.Object) []string {
|
||||
if !isEgressSvcForProxyGroup(o) {
|
||||
return nil
|
||||
}
|
||||
return []string{o.GetAnnotations()[AnnotationProxyGroup]}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/go-cmp/cmp"
|
||||
"go.uber.org/zap"
|
||||
@@ -17,10 +18,14 @@ import (
|
||||
networkingv1 "k8s.io/api/networking/v1"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/types"
|
||||
"k8s.io/client-go/tools/record"
|
||||
"sigs.k8s.io/controller-runtime/pkg/client/fake"
|
||||
"sigs.k8s.io/controller-runtime/pkg/reconcile"
|
||||
tsapi "tailscale.com/k8s-operator/apis/v1alpha1"
|
||||
"tailscale.com/kube/kubetypes"
|
||||
"tailscale.com/net/dns/resolvconffile"
|
||||
"tailscale.com/tstest"
|
||||
"tailscale.com/tstime"
|
||||
"tailscale.com/types/ptr"
|
||||
"tailscale.com/util/dnsname"
|
||||
"tailscale.com/util/mak"
|
||||
@@ -33,6 +38,7 @@ func TestLoadBalancerClass(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
clock := tstest.NewClock(tstest.ClockOpts{})
|
||||
sr := &ServiceReconciler{
|
||||
Client: fc,
|
||||
ssr: &tailscaleSTSReconciler{
|
||||
@@ -42,11 +48,13 @@ func TestLoadBalancerClass(t *testing.T) {
|
||||
operatorNamespace: "operator-ns",
|
||||
proxyImage: "tailscale/tailscale",
|
||||
},
|
||||
logger: zl.Sugar(),
|
||||
logger: zl.Sugar(),
|
||||
clock: clock,
|
||||
recorder: record.NewFakeRecorder(100),
|
||||
}
|
||||
|
||||
// Create a service that we should manage, and check that the initial round
|
||||
// of objects looks right.
|
||||
// Create a service that we should manage, but start with a miconfiguration
|
||||
// in the annotations.
|
||||
mustCreate(t, fc, &corev1.Service{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test",
|
||||
@@ -55,6 +63,9 @@ func TestLoadBalancerClass(t *testing.T) {
|
||||
// doesn't. So, set it explicitly because other code later depends
|
||||
// on it being set.
|
||||
UID: types.UID("1234-UID"),
|
||||
Annotations: map[string]string{
|
||||
AnnotationTailnetTargetFQDN: "invalid.example.com",
|
||||
},
|
||||
},
|
||||
Spec: corev1.ServiceSpec{
|
||||
ClusterIP: "10.20.30.40",
|
||||
@@ -65,6 +76,46 @@ func TestLoadBalancerClass(t *testing.T) {
|
||||
|
||||
expectReconciled(t, sr, "default", "test")
|
||||
|
||||
// The expected value of .status.conditions[0].LastTransitionTime until the
|
||||
// proxy becomes ready.
|
||||
t0 := conditionTime(clock)
|
||||
|
||||
// Should have an error about invalid config.
|
||||
want := &corev1.Service{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test",
|
||||
Namespace: "default",
|
||||
UID: types.UID("1234-UID"),
|
||||
Annotations: map[string]string{
|
||||
AnnotationTailnetTargetFQDN: "invalid.example.com",
|
||||
},
|
||||
},
|
||||
Spec: corev1.ServiceSpec{
|
||||
ClusterIP: "10.20.30.40",
|
||||
Type: corev1.ServiceTypeLoadBalancer,
|
||||
LoadBalancerClass: ptr.To("tailscale"),
|
||||
},
|
||||
Status: corev1.ServiceStatus{
|
||||
Conditions: []metav1.Condition{{
|
||||
Type: string(tsapi.ProxyReady),
|
||||
Status: metav1.ConditionFalse,
|
||||
LastTransitionTime: t0,
|
||||
Reason: reasonProxyInvalid,
|
||||
Message: `unable to provision proxy resources: invalid Service: invalid value of annotation tailscale.com/tailnet-fqdn: "invalid.example.com" does not appear to be a valid MagicDNS name`,
|
||||
}},
|
||||
},
|
||||
}
|
||||
expectEqual(t, fc, want, nil)
|
||||
|
||||
// Delete the misconfiguration so the proxy starts getting created on the
|
||||
// next reconcile.
|
||||
mustUpdate(t, fc, "default", "test", func(s *corev1.Service) {
|
||||
s.ObjectMeta.Annotations = nil
|
||||
})
|
||||
|
||||
clock.Advance(time.Second)
|
||||
expectReconciled(t, sr, "default", "test")
|
||||
|
||||
fullName, shortName := findGenName(t, fc, "default", "test", "svc")
|
||||
opts := configOpts{
|
||||
stsName: shortName,
|
||||
@@ -73,12 +124,26 @@ func TestLoadBalancerClass(t *testing.T) {
|
||||
parentType: "svc",
|
||||
hostname: "default-test",
|
||||
clusterTargetIP: "10.20.30.40",
|
||||
app: kubetypes.AppIngressProxy,
|
||||
}
|
||||
|
||||
expectEqual(t, fc, expectedSecret(t, opts), nil)
|
||||
expectEqual(t, fc, expectedSecret(t, fc, opts), nil)
|
||||
expectEqual(t, fc, expectedHeadlessService(shortName, "svc"), nil)
|
||||
expectEqual(t, fc, expectedSTS(t, fc, opts), removeHashAnnotation)
|
||||
|
||||
want.Annotations = nil
|
||||
want.ObjectMeta.Finalizers = []string{"tailscale.com/finalizer"}
|
||||
want.Status = corev1.ServiceStatus{
|
||||
Conditions: []metav1.Condition{{
|
||||
Type: string(tsapi.ProxyReady),
|
||||
Status: metav1.ConditionFalse,
|
||||
LastTransitionTime: t0, // Status is still false, no update to transition time
|
||||
Reason: reasonProxyPending,
|
||||
Message: "no Tailscale hostname known yet, waiting for proxy pod to finish auth",
|
||||
}},
|
||||
}
|
||||
expectEqual(t, fc, want, nil)
|
||||
|
||||
// Normally the Tailscale proxy pod would come up here and write its info
|
||||
// into the secret. Simulate that, then verify reconcile again and verify
|
||||
// that we get to the end.
|
||||
@@ -90,33 +155,16 @@ func TestLoadBalancerClass(t *testing.T) {
|
||||
s.Data["device_fqdn"] = []byte("tailscale.device.name.")
|
||||
s.Data["device_ips"] = []byte(`["100.99.98.97", "2c0a:8083:94d4:2012:3165:34a5:3616:5fdf"]`)
|
||||
})
|
||||
clock.Advance(time.Second)
|
||||
expectReconciled(t, sr, "default", "test")
|
||||
want := &corev1.Service{
|
||||
TypeMeta: metav1.TypeMeta{
|
||||
Kind: "Service",
|
||||
APIVersion: "v1",
|
||||
},
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test",
|
||||
Namespace: "default",
|
||||
Finalizers: []string{"tailscale.com/finalizer"},
|
||||
UID: types.UID("1234-UID"),
|
||||
},
|
||||
Spec: corev1.ServiceSpec{
|
||||
ClusterIP: "10.20.30.40",
|
||||
Type: corev1.ServiceTypeLoadBalancer,
|
||||
LoadBalancerClass: ptr.To("tailscale"),
|
||||
},
|
||||
Status: corev1.ServiceStatus{
|
||||
LoadBalancer: corev1.LoadBalancerStatus{
|
||||
Ingress: []corev1.LoadBalancerIngress{
|
||||
{
|
||||
Hostname: "tailscale.device.name",
|
||||
},
|
||||
{
|
||||
IP: "100.99.98.97",
|
||||
},
|
||||
},
|
||||
want.Status.Conditions = proxyCreatedCondition(clock)
|
||||
want.Status.LoadBalancer = corev1.LoadBalancerStatus{
|
||||
Ingress: []corev1.LoadBalancerIngress{
|
||||
{
|
||||
Hostname: "tailscale.device.name",
|
||||
},
|
||||
{
|
||||
IP: "100.99.98.97",
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -144,11 +192,9 @@ func TestLoadBalancerClass(t *testing.T) {
|
||||
expectMissing[appsv1.StatefulSet](t, fc, "operator-ns", shortName)
|
||||
expectMissing[corev1.Service](t, fc, "operator-ns", shortName)
|
||||
expectMissing[corev1.Secret](t, fc, "operator-ns", fullName)
|
||||
|
||||
// Note that the Tailscale-specific condition status should be gone now.
|
||||
want = &corev1.Service{
|
||||
TypeMeta: metav1.TypeMeta{
|
||||
Kind: "Service",
|
||||
APIVersion: "v1",
|
||||
},
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test",
|
||||
Namespace: "default",
|
||||
@@ -170,6 +216,7 @@ func TestTailnetTargetFQDNAnnotation(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tailnetTargetFQDN := "foo.bar.ts.net."
|
||||
clock := tstest.NewClock(tstest.ClockOpts{})
|
||||
sr := &ServiceReconciler{
|
||||
Client: fc,
|
||||
ssr: &tailscaleSTSReconciler{
|
||||
@@ -180,6 +227,7 @@ func TestTailnetTargetFQDNAnnotation(t *testing.T) {
|
||||
proxyImage: "tailscale/tailscale",
|
||||
},
|
||||
logger: zl.Sugar(),
|
||||
clock: clock,
|
||||
}
|
||||
|
||||
// Create a service that we should manage, and check that the initial round
|
||||
@@ -214,16 +262,13 @@ func TestTailnetTargetFQDNAnnotation(t *testing.T) {
|
||||
parentType: "svc",
|
||||
tailnetTargetFQDN: tailnetTargetFQDN,
|
||||
hostname: "default-test",
|
||||
app: kubetypes.AppEgressProxy,
|
||||
}
|
||||
|
||||
expectEqual(t, fc, expectedSecret(t, o), nil)
|
||||
expectEqual(t, fc, expectedSecret(t, fc, o), nil)
|
||||
expectEqual(t, fc, expectedHeadlessService(shortName, "svc"), nil)
|
||||
expectEqual(t, fc, expectedSTS(t, fc, o), removeHashAnnotation)
|
||||
want := &corev1.Service{
|
||||
TypeMeta: metav1.TypeMeta{
|
||||
Kind: "Service",
|
||||
APIVersion: "v1",
|
||||
},
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test",
|
||||
Namespace: "default",
|
||||
@@ -238,9 +283,12 @@ func TestTailnetTargetFQDNAnnotation(t *testing.T) {
|
||||
Type: corev1.ServiceTypeExternalName,
|
||||
Selector: nil,
|
||||
},
|
||||
Status: corev1.ServiceStatus{
|
||||
Conditions: proxyCreatedCondition(clock),
|
||||
},
|
||||
}
|
||||
expectEqual(t, fc, want, nil)
|
||||
expectEqual(t, fc, expectedSecret(t, o), nil)
|
||||
expectEqual(t, fc, expectedSecret(t, fc, o), nil)
|
||||
expectEqual(t, fc, expectedHeadlessService(shortName, "svc"), nil)
|
||||
expectEqual(t, fc, expectedSTS(t, fc, o), removeHashAnnotation)
|
||||
|
||||
@@ -280,6 +328,7 @@ func TestTailnetTargetIPAnnotation(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tailnetTargetIP := "100.66.66.66"
|
||||
clock := tstest.NewClock(tstest.ClockOpts{})
|
||||
sr := &ServiceReconciler{
|
||||
Client: fc,
|
||||
ssr: &tailscaleSTSReconciler{
|
||||
@@ -290,6 +339,7 @@ func TestTailnetTargetIPAnnotation(t *testing.T) {
|
||||
proxyImage: "tailscale/tailscale",
|
||||
},
|
||||
logger: zl.Sugar(),
|
||||
clock: clock,
|
||||
}
|
||||
|
||||
// Create a service that we should manage, and check that the initial round
|
||||
@@ -324,16 +374,13 @@ func TestTailnetTargetIPAnnotation(t *testing.T) {
|
||||
parentType: "svc",
|
||||
tailnetTargetIP: tailnetTargetIP,
|
||||
hostname: "default-test",
|
||||
app: kubetypes.AppEgressProxy,
|
||||
}
|
||||
|
||||
expectEqual(t, fc, expectedSecret(t, o), nil)
|
||||
expectEqual(t, fc, expectedSecret(t, fc, o), nil)
|
||||
expectEqual(t, fc, expectedHeadlessService(shortName, "svc"), nil)
|
||||
expectEqual(t, fc, expectedSTS(t, fc, o), removeHashAnnotation)
|
||||
want := &corev1.Service{
|
||||
TypeMeta: metav1.TypeMeta{
|
||||
Kind: "Service",
|
||||
APIVersion: "v1",
|
||||
},
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test",
|
||||
Namespace: "default",
|
||||
@@ -348,9 +395,12 @@ func TestTailnetTargetIPAnnotation(t *testing.T) {
|
||||
Type: corev1.ServiceTypeExternalName,
|
||||
Selector: nil,
|
||||
},
|
||||
Status: corev1.ServiceStatus{
|
||||
Conditions: proxyCreatedCondition(clock),
|
||||
},
|
||||
}
|
||||
expectEqual(t, fc, want, nil)
|
||||
expectEqual(t, fc, expectedSecret(t, o), nil)
|
||||
expectEqual(t, fc, expectedSecret(t, fc, o), nil)
|
||||
expectEqual(t, fc, expectedHeadlessService(shortName, "svc"), nil)
|
||||
expectEqual(t, fc, expectedSTS(t, fc, o), removeHashAnnotation)
|
||||
|
||||
@@ -382,6 +432,148 @@ func TestTailnetTargetIPAnnotation(t *testing.T) {
|
||||
expectMissing[corev1.Secret](t, fc, "operator-ns", fullName)
|
||||
}
|
||||
|
||||
func TestTailnetTargetIPAnnotation_IPCouldNotBeParsed(t *testing.T) {
|
||||
fc := fake.NewFakeClient()
|
||||
ft := &fakeTSClient{}
|
||||
zl, err := zap.NewDevelopment()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
clock := tstest.NewClock(tstest.ClockOpts{})
|
||||
sr := &ServiceReconciler{
|
||||
Client: fc,
|
||||
ssr: &tailscaleSTSReconciler{
|
||||
Client: fc,
|
||||
tsClient: ft,
|
||||
defaultTags: []string{"tag:k8s"},
|
||||
operatorNamespace: "operator-ns",
|
||||
proxyImage: "tailscale/tailscale",
|
||||
},
|
||||
logger: zl.Sugar(),
|
||||
clock: clock,
|
||||
recorder: record.NewFakeRecorder(100),
|
||||
}
|
||||
tailnetTargetIP := "invalid-ip"
|
||||
mustCreate(t, fc, &corev1.Service{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test",
|
||||
Namespace: "default",
|
||||
|
||||
UID: types.UID("1234-UID"),
|
||||
Annotations: map[string]string{
|
||||
AnnotationTailnetTargetIP: tailnetTargetIP,
|
||||
},
|
||||
},
|
||||
Spec: corev1.ServiceSpec{
|
||||
ClusterIP: "10.20.30.40",
|
||||
Type: corev1.ServiceTypeLoadBalancer,
|
||||
LoadBalancerClass: ptr.To("tailscale"),
|
||||
},
|
||||
})
|
||||
|
||||
expectReconciled(t, sr, "default", "test")
|
||||
|
||||
t0 := conditionTime(clock)
|
||||
|
||||
want := &corev1.Service{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test",
|
||||
Namespace: "default",
|
||||
UID: types.UID("1234-UID"),
|
||||
Annotations: map[string]string{
|
||||
AnnotationTailnetTargetIP: tailnetTargetIP,
|
||||
},
|
||||
},
|
||||
Spec: corev1.ServiceSpec{
|
||||
ClusterIP: "10.20.30.40",
|
||||
Type: corev1.ServiceTypeLoadBalancer,
|
||||
LoadBalancerClass: ptr.To("tailscale"),
|
||||
},
|
||||
Status: corev1.ServiceStatus{
|
||||
Conditions: []metav1.Condition{{
|
||||
Type: string(tsapi.ProxyReady),
|
||||
Status: metav1.ConditionFalse,
|
||||
LastTransitionTime: t0,
|
||||
Reason: reasonProxyInvalid,
|
||||
Message: `unable to provision proxy resources: invalid Service: invalid value of annotation tailscale.com/tailnet-ip: "invalid-ip" could not be parsed as a valid IP Address, error: ParseAddr("invalid-ip"): unable to parse IP`,
|
||||
}},
|
||||
},
|
||||
}
|
||||
|
||||
expectEqual(t, fc, want, nil)
|
||||
}
|
||||
|
||||
func TestTailnetTargetIPAnnotation_InvalidIP(t *testing.T) {
|
||||
fc := fake.NewFakeClient()
|
||||
ft := &fakeTSClient{}
|
||||
zl, err := zap.NewDevelopment()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
clock := tstest.NewClock(tstest.ClockOpts{})
|
||||
sr := &ServiceReconciler{
|
||||
Client: fc,
|
||||
ssr: &tailscaleSTSReconciler{
|
||||
Client: fc,
|
||||
tsClient: ft,
|
||||
defaultTags: []string{"tag:k8s"},
|
||||
operatorNamespace: "operator-ns",
|
||||
proxyImage: "tailscale/tailscale",
|
||||
},
|
||||
logger: zl.Sugar(),
|
||||
clock: clock,
|
||||
recorder: record.NewFakeRecorder(100),
|
||||
}
|
||||
tailnetTargetIP := "999.999.999.999"
|
||||
mustCreate(t, fc, &corev1.Service{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test",
|
||||
Namespace: "default",
|
||||
|
||||
UID: types.UID("1234-UID"),
|
||||
Annotations: map[string]string{
|
||||
AnnotationTailnetTargetIP: tailnetTargetIP,
|
||||
},
|
||||
},
|
||||
Spec: corev1.ServiceSpec{
|
||||
ClusterIP: "10.20.30.40",
|
||||
Type: corev1.ServiceTypeLoadBalancer,
|
||||
LoadBalancerClass: ptr.To("tailscale"),
|
||||
},
|
||||
})
|
||||
|
||||
expectReconciled(t, sr, "default", "test")
|
||||
|
||||
t0 := conditionTime(clock)
|
||||
|
||||
want := &corev1.Service{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test",
|
||||
Namespace: "default",
|
||||
UID: types.UID("1234-UID"),
|
||||
Annotations: map[string]string{
|
||||
AnnotationTailnetTargetIP: tailnetTargetIP,
|
||||
},
|
||||
},
|
||||
Spec: corev1.ServiceSpec{
|
||||
ClusterIP: "10.20.30.40",
|
||||
Type: corev1.ServiceTypeLoadBalancer,
|
||||
LoadBalancerClass: ptr.To("tailscale"),
|
||||
},
|
||||
Status: corev1.ServiceStatus{
|
||||
Conditions: []metav1.Condition{{
|
||||
Type: string(tsapi.ProxyReady),
|
||||
Status: metav1.ConditionFalse,
|
||||
LastTransitionTime: t0,
|
||||
Reason: reasonProxyInvalid,
|
||||
Message: `unable to provision proxy resources: invalid Service: invalid value of annotation tailscale.com/tailnet-ip: "999.999.999.999" could not be parsed as a valid IP Address, error: ParseAddr("999.999.999.999"): IPv4 field has value >255`,
|
||||
}},
|
||||
},
|
||||
}
|
||||
|
||||
expectEqual(t, fc, want, nil)
|
||||
}
|
||||
|
||||
func TestAnnotations(t *testing.T) {
|
||||
fc := fake.NewFakeClient()
|
||||
ft := &fakeTSClient{}
|
||||
@@ -389,6 +581,7 @@ func TestAnnotations(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
clock := tstest.NewClock(tstest.ClockOpts{})
|
||||
sr := &ServiceReconciler{
|
||||
Client: fc,
|
||||
ssr: &tailscaleSTSReconciler{
|
||||
@@ -399,6 +592,7 @@ func TestAnnotations(t *testing.T) {
|
||||
proxyImage: "tailscale/tailscale",
|
||||
},
|
||||
logger: zl.Sugar(),
|
||||
clock: clock,
|
||||
}
|
||||
|
||||
// Create a service that we should manage, and check that the initial round
|
||||
@@ -431,16 +625,13 @@ func TestAnnotations(t *testing.T) {
|
||||
parentType: "svc",
|
||||
hostname: "default-test",
|
||||
clusterTargetIP: "10.20.30.40",
|
||||
app: kubetypes.AppIngressProxy,
|
||||
}
|
||||
|
||||
expectEqual(t, fc, expectedSecret(t, o), nil)
|
||||
expectEqual(t, fc, expectedSecret(t, fc, o), nil)
|
||||
expectEqual(t, fc, expectedHeadlessService(shortName, "svc"), nil)
|
||||
expectEqual(t, fc, expectedSTS(t, fc, o), removeHashAnnotation)
|
||||
want := &corev1.Service{
|
||||
TypeMeta: metav1.TypeMeta{
|
||||
Kind: "Service",
|
||||
APIVersion: "v1",
|
||||
},
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test",
|
||||
Namespace: "default",
|
||||
@@ -454,6 +645,9 @@ func TestAnnotations(t *testing.T) {
|
||||
ClusterIP: "10.20.30.40",
|
||||
Type: corev1.ServiceTypeClusterIP,
|
||||
},
|
||||
Status: corev1.ServiceStatus{
|
||||
Conditions: proxyCreatedCondition(clock),
|
||||
},
|
||||
}
|
||||
expectEqual(t, fc, want, nil)
|
||||
|
||||
@@ -473,10 +667,6 @@ func TestAnnotations(t *testing.T) {
|
||||
expectMissing[corev1.Service](t, fc, "operator-ns", shortName)
|
||||
expectMissing[corev1.Secret](t, fc, "operator-ns", fullName)
|
||||
want = &corev1.Service{
|
||||
TypeMeta: metav1.TypeMeta{
|
||||
Kind: "Service",
|
||||
APIVersion: "v1",
|
||||
},
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test",
|
||||
Namespace: "default",
|
||||
@@ -497,6 +687,7 @@ func TestAnnotationIntoLB(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
clock := tstest.NewClock(tstest.ClockOpts{})
|
||||
sr := &ServiceReconciler{
|
||||
Client: fc,
|
||||
ssr: &tailscaleSTSReconciler{
|
||||
@@ -507,6 +698,7 @@ func TestAnnotationIntoLB(t *testing.T) {
|
||||
proxyImage: "tailscale/tailscale",
|
||||
},
|
||||
logger: zl.Sugar(),
|
||||
clock: clock,
|
||||
}
|
||||
|
||||
// Create a service that we should manage, and check that the initial round
|
||||
@@ -539,9 +731,10 @@ func TestAnnotationIntoLB(t *testing.T) {
|
||||
parentType: "svc",
|
||||
hostname: "default-test",
|
||||
clusterTargetIP: "10.20.30.40",
|
||||
app: kubetypes.AppIngressProxy,
|
||||
}
|
||||
|
||||
expectEqual(t, fc, expectedSecret(t, o), nil)
|
||||
expectEqual(t, fc, expectedSecret(t, fc, o), nil)
|
||||
expectEqual(t, fc, expectedHeadlessService(shortName, "svc"), nil)
|
||||
expectEqual(t, fc, expectedSTS(t, fc, o), removeHashAnnotation)
|
||||
|
||||
@@ -558,10 +751,6 @@ func TestAnnotationIntoLB(t *testing.T) {
|
||||
})
|
||||
expectReconciled(t, sr, "default", "test")
|
||||
want := &corev1.Service{
|
||||
TypeMeta: metav1.TypeMeta{
|
||||
Kind: "Service",
|
||||
APIVersion: "v1",
|
||||
},
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test",
|
||||
Namespace: "default",
|
||||
@@ -575,6 +764,9 @@ func TestAnnotationIntoLB(t *testing.T) {
|
||||
ClusterIP: "10.20.30.40",
|
||||
Type: corev1.ServiceTypeClusterIP,
|
||||
},
|
||||
Status: corev1.ServiceStatus{
|
||||
Conditions: proxyCreatedCondition(clock),
|
||||
},
|
||||
}
|
||||
expectEqual(t, fc, want, nil)
|
||||
|
||||
@@ -592,10 +784,6 @@ func TestAnnotationIntoLB(t *testing.T) {
|
||||
// ... but the service should have a LoadBalancer status.
|
||||
|
||||
want = &corev1.Service{
|
||||
TypeMeta: metav1.TypeMeta{
|
||||
Kind: "Service",
|
||||
APIVersion: "v1",
|
||||
},
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test",
|
||||
Namespace: "default",
|
||||
@@ -618,6 +806,7 @@ func TestAnnotationIntoLB(t *testing.T) {
|
||||
},
|
||||
},
|
||||
},
|
||||
Conditions: proxyCreatedCondition(clock),
|
||||
},
|
||||
}
|
||||
expectEqual(t, fc, want, nil)
|
||||
@@ -630,6 +819,7 @@ func TestLBIntoAnnotation(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
clock := tstest.NewClock(tstest.ClockOpts{})
|
||||
sr := &ServiceReconciler{
|
||||
Client: fc,
|
||||
ssr: &tailscaleSTSReconciler{
|
||||
@@ -640,6 +830,7 @@ func TestLBIntoAnnotation(t *testing.T) {
|
||||
proxyImage: "tailscale/tailscale",
|
||||
},
|
||||
logger: zl.Sugar(),
|
||||
clock: clock,
|
||||
}
|
||||
|
||||
// Create a service that we should manage, and check that the initial round
|
||||
@@ -670,9 +861,10 @@ func TestLBIntoAnnotation(t *testing.T) {
|
||||
parentType: "svc",
|
||||
hostname: "default-test",
|
||||
clusterTargetIP: "10.20.30.40",
|
||||
app: kubetypes.AppIngressProxy,
|
||||
}
|
||||
|
||||
expectEqual(t, fc, expectedSecret(t, o), nil)
|
||||
expectEqual(t, fc, expectedSecret(t, fc, o), nil)
|
||||
expectEqual(t, fc, expectedHeadlessService(shortName, "svc"), nil)
|
||||
expectEqual(t, fc, expectedSTS(t, fc, o), removeHashAnnotation)
|
||||
|
||||
@@ -689,10 +881,6 @@ func TestLBIntoAnnotation(t *testing.T) {
|
||||
})
|
||||
expectReconciled(t, sr, "default", "test")
|
||||
want := &corev1.Service{
|
||||
TypeMeta: metav1.TypeMeta{
|
||||
Kind: "Service",
|
||||
APIVersion: "v1",
|
||||
},
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test",
|
||||
Namespace: "default",
|
||||
@@ -715,6 +903,7 @@ func TestLBIntoAnnotation(t *testing.T) {
|
||||
},
|
||||
},
|
||||
},
|
||||
Conditions: proxyCreatedCondition(clock),
|
||||
},
|
||||
}
|
||||
expectEqual(t, fc, want, nil)
|
||||
@@ -740,10 +929,6 @@ func TestLBIntoAnnotation(t *testing.T) {
|
||||
expectEqual(t, fc, expectedSTS(t, fc, o), removeHashAnnotation)
|
||||
|
||||
want = &corev1.Service{
|
||||
TypeMeta: metav1.TypeMeta{
|
||||
Kind: "Service",
|
||||
APIVersion: "v1",
|
||||
},
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test",
|
||||
Namespace: "default",
|
||||
@@ -757,6 +942,9 @@ func TestLBIntoAnnotation(t *testing.T) {
|
||||
ClusterIP: "10.20.30.40",
|
||||
Type: corev1.ServiceTypeClusterIP,
|
||||
},
|
||||
Status: corev1.ServiceStatus{
|
||||
Conditions: proxyCreatedCondition(clock),
|
||||
},
|
||||
}
|
||||
expectEqual(t, fc, want, nil)
|
||||
}
|
||||
@@ -768,6 +956,7 @@ func TestCustomHostname(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
clock := tstest.NewClock(tstest.ClockOpts{})
|
||||
sr := &ServiceReconciler{
|
||||
Client: fc,
|
||||
ssr: &tailscaleSTSReconciler{
|
||||
@@ -778,6 +967,7 @@ func TestCustomHostname(t *testing.T) {
|
||||
proxyImage: "tailscale/tailscale",
|
||||
},
|
||||
logger: zl.Sugar(),
|
||||
clock: clock,
|
||||
}
|
||||
|
||||
// Create a service that we should manage, and check that the initial round
|
||||
@@ -811,16 +1001,13 @@ func TestCustomHostname(t *testing.T) {
|
||||
parentType: "svc",
|
||||
hostname: "reindeer-flotilla",
|
||||
clusterTargetIP: "10.20.30.40",
|
||||
app: kubetypes.AppIngressProxy,
|
||||
}
|
||||
|
||||
expectEqual(t, fc, expectedSecret(t, o), nil)
|
||||
expectEqual(t, fc, expectedSecret(t, fc, o), nil)
|
||||
expectEqual(t, fc, expectedHeadlessService(shortName, "svc"), nil)
|
||||
expectEqual(t, fc, expectedSTS(t, fc, o), removeHashAnnotation)
|
||||
want := &corev1.Service{
|
||||
TypeMeta: metav1.TypeMeta{
|
||||
Kind: "Service",
|
||||
APIVersion: "v1",
|
||||
},
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test",
|
||||
Namespace: "default",
|
||||
@@ -835,6 +1022,9 @@ func TestCustomHostname(t *testing.T) {
|
||||
ClusterIP: "10.20.30.40",
|
||||
Type: corev1.ServiceTypeClusterIP,
|
||||
},
|
||||
Status: corev1.ServiceStatus{
|
||||
Conditions: proxyCreatedCondition(clock),
|
||||
},
|
||||
}
|
||||
expectEqual(t, fc, want, nil)
|
||||
|
||||
@@ -854,10 +1044,6 @@ func TestCustomHostname(t *testing.T) {
|
||||
expectMissing[corev1.Service](t, fc, "operator-ns", shortName)
|
||||
expectMissing[corev1.Secret](t, fc, "operator-ns", fullName)
|
||||
want = &corev1.Service{
|
||||
TypeMeta: metav1.TypeMeta{
|
||||
Kind: "Service",
|
||||
APIVersion: "v1",
|
||||
},
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test",
|
||||
Namespace: "default",
|
||||
@@ -881,6 +1067,7 @@ func TestCustomPriorityClassName(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
clock := tstest.NewClock(tstest.ClockOpts{})
|
||||
sr := &ServiceReconciler{
|
||||
Client: fc,
|
||||
ssr: &tailscaleSTSReconciler{
|
||||
@@ -892,6 +1079,7 @@ func TestCustomPriorityClassName(t *testing.T) {
|
||||
proxyPriorityClassName: "custom-priority-class-name",
|
||||
},
|
||||
logger: zl.Sugar(),
|
||||
clock: clock,
|
||||
}
|
||||
|
||||
// Create a service that we should manage, and check that the initial round
|
||||
@@ -926,6 +1114,7 @@ func TestCustomPriorityClassName(t *testing.T) {
|
||||
hostname: "tailscale-critical",
|
||||
priorityClassName: "custom-priority-class-name",
|
||||
clusterTargetIP: "10.20.30.40",
|
||||
app: kubetypes.AppIngressProxy,
|
||||
}
|
||||
|
||||
expectEqual(t, fc, expectedSTS(t, fc, o), removeHashAnnotation)
|
||||
@@ -935,10 +1124,14 @@ func TestProxyClassForService(t *testing.T) {
|
||||
// Setup
|
||||
pc := &tsapi.ProxyClass{
|
||||
ObjectMeta: metav1.ObjectMeta{Name: "custom-metadata"},
|
||||
Spec: tsapi.ProxyClassSpec{StatefulSet: &tsapi.StatefulSet{
|
||||
Labels: map[string]string{"foo": "bar"},
|
||||
Annotations: map[string]string{"bar.io/foo": "some-val"},
|
||||
Pod: &tsapi.Pod{Annotations: map[string]string{"foo.io/bar": "some-val"}}}},
|
||||
Spec: tsapi.ProxyClassSpec{
|
||||
TailscaleConfig: &tsapi.TailscaleConfig{
|
||||
AcceptRoutes: true,
|
||||
},
|
||||
StatefulSet: &tsapi.StatefulSet{
|
||||
Labels: map[string]string{"foo": "bar"},
|
||||
Annotations: map[string]string{"bar.io/foo": "some-val"},
|
||||
Pod: &tsapi.Pod{Annotations: map[string]string{"foo.io/bar": "some-val"}}}},
|
||||
}
|
||||
fc := fake.NewClientBuilder().
|
||||
WithScheme(tsapi.GlobalScheme).
|
||||
@@ -950,6 +1143,7 @@ func TestProxyClassForService(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
clock := tstest.NewClock(tstest.ClockOpts{})
|
||||
sr := &ServiceReconciler{
|
||||
Client: fc,
|
||||
ssr: &tailscaleSTSReconciler{
|
||||
@@ -960,6 +1154,7 @@ func TestProxyClassForService(t *testing.T) {
|
||||
proxyImage: "tailscale/tailscale",
|
||||
},
|
||||
logger: zl.Sugar(),
|
||||
clock: clock,
|
||||
}
|
||||
|
||||
// 1. A new tailscale LoadBalancer Service is created without any
|
||||
@@ -988,8 +1183,9 @@ func TestProxyClassForService(t *testing.T) {
|
||||
parentType: "svc",
|
||||
hostname: "default-test",
|
||||
clusterTargetIP: "10.20.30.40",
|
||||
app: kubetypes.AppIngressProxy,
|
||||
}
|
||||
expectEqual(t, fc, expectedSecret(t, opts), nil)
|
||||
expectEqual(t, fc, expectedSecret(t, fc, opts), nil)
|
||||
expectEqual(t, fc, expectedHeadlessService(shortName, "svc"), nil)
|
||||
expectEqual(t, fc, expectedSTS(t, fc, opts), removeHashAnnotation)
|
||||
|
||||
@@ -1001,21 +1197,23 @@ func TestProxyClassForService(t *testing.T) {
|
||||
})
|
||||
expectReconciled(t, sr, "default", "test")
|
||||
expectEqual(t, fc, expectedSTS(t, fc, opts), removeHashAnnotation)
|
||||
expectEqual(t, fc, expectedSecret(t, fc, opts), nil)
|
||||
|
||||
// 3. ProxyClass is set to Ready, the Service gets reconciled by the
|
||||
// services-reconciler and the customization from the ProxyClass is
|
||||
// applied to the proxy resources.
|
||||
mustUpdateStatus(t, fc, "", "custom-metadata", func(pc *tsapi.ProxyClass) {
|
||||
pc.Status = tsapi.ProxyClassStatus{
|
||||
Conditions: []tsapi.ConnectorCondition{{
|
||||
Conditions: []metav1.Condition{{
|
||||
Status: metav1.ConditionTrue,
|
||||
Type: tsapi.ProxyClassready,
|
||||
Type: string(tsapi.ProxyClassReady),
|
||||
ObservedGeneration: pc.Generation,
|
||||
}}}
|
||||
})
|
||||
opts.proxyClass = pc.Name
|
||||
expectReconciled(t, sr, "default", "test")
|
||||
expectEqual(t, fc, expectedSTS(t, fc, opts), removeHashAnnotation)
|
||||
expectEqual(t, fc, expectedSecret(t, fc, opts), removeAuthKeyIfExistsModifier(t))
|
||||
|
||||
// 4. tailscale.com/proxy-class label is removed from the Service, the
|
||||
// configuration from the ProxyClass is removed from the cluster
|
||||
@@ -1035,6 +1233,7 @@ func TestDefaultLoadBalancer(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
clock := tstest.NewClock(tstest.ClockOpts{})
|
||||
sr := &ServiceReconciler{
|
||||
Client: fc,
|
||||
ssr: &tailscaleSTSReconciler{
|
||||
@@ -1045,6 +1244,7 @@ func TestDefaultLoadBalancer(t *testing.T) {
|
||||
proxyImage: "tailscale/tailscale",
|
||||
},
|
||||
logger: zl.Sugar(),
|
||||
clock: clock,
|
||||
isDefaultLoadBalancer: true,
|
||||
}
|
||||
|
||||
@@ -1077,6 +1277,7 @@ func TestDefaultLoadBalancer(t *testing.T) {
|
||||
parentType: "svc",
|
||||
hostname: "default-test",
|
||||
clusterTargetIP: "10.20.30.40",
|
||||
app: kubetypes.AppIngressProxy,
|
||||
}
|
||||
expectEqual(t, fc, expectedSTS(t, fc, o), removeHashAnnotation)
|
||||
|
||||
@@ -1089,6 +1290,7 @@ func TestProxyFirewallMode(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
clock := tstest.NewClock(tstest.ClockOpts{})
|
||||
sr := &ServiceReconciler{
|
||||
Client: fc,
|
||||
ssr: &tailscaleSTSReconciler{
|
||||
@@ -1100,6 +1302,7 @@ func TestProxyFirewallMode(t *testing.T) {
|
||||
tsFirewallMode: "nftables",
|
||||
},
|
||||
logger: zl.Sugar(),
|
||||
clock: clock,
|
||||
isDefaultLoadBalancer: true,
|
||||
}
|
||||
|
||||
@@ -1131,6 +1334,7 @@ func TestProxyFirewallMode(t *testing.T) {
|
||||
hostname: "default-test",
|
||||
firewallMode: "nftables",
|
||||
clusterTargetIP: "10.20.30.40",
|
||||
app: kubetypes.AppIngressProxy,
|
||||
}
|
||||
expectEqual(t, fc, expectedSTS(t, fc, o), removeHashAnnotation)
|
||||
}
|
||||
@@ -1142,6 +1346,7 @@ func TestTailscaledConfigfileHash(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
clock := tstest.NewClock(tstest.ClockOpts{})
|
||||
sr := &ServiceReconciler{
|
||||
Client: fc,
|
||||
ssr: &tailscaleSTSReconciler{
|
||||
@@ -1152,6 +1357,7 @@ func TestTailscaledConfigfileHash(t *testing.T) {
|
||||
proxyImage: "tailscale/tailscale",
|
||||
},
|
||||
logger: zl.Sugar(),
|
||||
clock: clock,
|
||||
isDefaultLoadBalancer: true,
|
||||
}
|
||||
|
||||
@@ -1182,7 +1388,8 @@ func TestTailscaledConfigfileHash(t *testing.T) {
|
||||
parentType: "svc",
|
||||
hostname: "default-test",
|
||||
clusterTargetIP: "10.20.30.40",
|
||||
confFileHash: "e09bededa0379920141cbd0b0dbdf9b8b66545877f9e8397423f5ce3e1ba439e",
|
||||
confFileHash: "a67b5ad3ff605531c822327e8f1a23dd0846e1075b722c13402f7d5d0ba32ba2",
|
||||
app: kubetypes.AppIngressProxy,
|
||||
}
|
||||
expectEqual(t, fc, expectedSTS(t, fc, o), nil)
|
||||
|
||||
@@ -1192,7 +1399,7 @@ func TestTailscaledConfigfileHash(t *testing.T) {
|
||||
mak.Set(&svc.Annotations, AnnotationHostname, "another-test")
|
||||
})
|
||||
o.hostname = "another-test"
|
||||
o.confFileHash = "5d754cf55463135ee34aa9821f2fd8483b53eb0570c3740c84a086304f427684"
|
||||
o.confFileHash = "888a993ebee20ad6be99623b45015339de117946850cf1252bede0b570e04293"
|
||||
expectReconciled(t, sr, "default", "test")
|
||||
expectEqual(t, fc, expectedSTS(t, fc, o), nil)
|
||||
}
|
||||
@@ -1422,6 +1629,72 @@ func Test_clusterDomainFromResolverConf(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
func Test_authKeyRemoval(t *testing.T) {
|
||||
fc := fake.NewFakeClient()
|
||||
ft := &fakeTSClient{}
|
||||
zl, err := zap.NewDevelopment()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// 1. A new Service that should be exposed via Tailscale gets created, a Secret with a config that contains auth
|
||||
// key is generated.
|
||||
clock := tstest.NewClock(tstest.ClockOpts{})
|
||||
sr := &ServiceReconciler{
|
||||
Client: fc,
|
||||
ssr: &tailscaleSTSReconciler{
|
||||
Client: fc,
|
||||
tsClient: ft,
|
||||
defaultTags: []string{"tag:k8s"},
|
||||
operatorNamespace: "operator-ns",
|
||||
proxyImage: "tailscale/tailscale",
|
||||
},
|
||||
logger: zl.Sugar(),
|
||||
clock: clock,
|
||||
}
|
||||
|
||||
mustCreate(t, fc, &corev1.Service{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test",
|
||||
Namespace: "default",
|
||||
UID: types.UID("1234-UID"),
|
||||
},
|
||||
Spec: corev1.ServiceSpec{
|
||||
ClusterIP: "10.20.30.40",
|
||||
Type: corev1.ServiceTypeLoadBalancer,
|
||||
LoadBalancerClass: ptr.To("tailscale"),
|
||||
},
|
||||
})
|
||||
|
||||
expectReconciled(t, sr, "default", "test")
|
||||
|
||||
fullName, shortName := findGenName(t, fc, "default", "test", "svc")
|
||||
opts := configOpts{
|
||||
stsName: shortName,
|
||||
secretName: fullName,
|
||||
namespace: "default",
|
||||
parentType: "svc",
|
||||
hostname: "default-test",
|
||||
clusterTargetIP: "10.20.30.40",
|
||||
app: kubetypes.AppIngressProxy,
|
||||
}
|
||||
|
||||
expectEqual(t, fc, expectedSecret(t, fc, opts), nil)
|
||||
expectEqual(t, fc, expectedHeadlessService(shortName, "svc"), nil)
|
||||
expectEqual(t, fc, expectedSTS(t, fc, opts), removeHashAnnotation)
|
||||
|
||||
// 2. Apply update to the Secret that imitates the proxy setting device_id.
|
||||
s := expectedSecret(t, fc, opts)
|
||||
mustUpdate(t, fc, s.Namespace, s.Name, func(s *corev1.Secret) {
|
||||
mak.Set(&s.Data, "device_id", []byte("dkkdi4CNTRL"))
|
||||
})
|
||||
|
||||
// 3. Config should no longer contain auth key
|
||||
expectReconciled(t, sr, "default", "test")
|
||||
opts.shouldRemoveAuthKey = true
|
||||
opts.secretExtraData = map[string][]byte{"device_id": []byte("dkkdi4CNTRL")}
|
||||
expectEqual(t, fc, expectedSecret(t, fc, opts), nil)
|
||||
}
|
||||
|
||||
func Test_externalNameService(t *testing.T) {
|
||||
fc := fake.NewFakeClient()
|
||||
@@ -1433,6 +1706,7 @@ func Test_externalNameService(t *testing.T) {
|
||||
|
||||
// 1. A External name Service that should be exposed via Tailscale gets
|
||||
// created.
|
||||
clock := tstest.NewClock(tstest.ClockOpts{})
|
||||
sr := &ServiceReconciler{
|
||||
Client: fc,
|
||||
ssr: &tailscaleSTSReconciler{
|
||||
@@ -1443,6 +1717,7 @@ func Test_externalNameService(t *testing.T) {
|
||||
proxyImage: "tailscale/tailscale",
|
||||
},
|
||||
logger: zl.Sugar(),
|
||||
clock: clock,
|
||||
}
|
||||
|
||||
// 1. Create an ExternalName Service that we should manage, and check that the initial round
|
||||
@@ -1475,9 +1750,10 @@ func Test_externalNameService(t *testing.T) {
|
||||
parentType: "svc",
|
||||
hostname: "default-test",
|
||||
clusterTargetDNS: "foo.com",
|
||||
app: kubetypes.AppIngressProxy,
|
||||
}
|
||||
|
||||
expectEqual(t, fc, expectedSecret(t, opts), nil)
|
||||
expectEqual(t, fc, expectedSecret(t, fc, opts), nil)
|
||||
expectEqual(t, fc, expectedHeadlessService(shortName, "svc"), nil)
|
||||
expectEqual(t, fc, expectedSTS(t, fc, opts), removeHashAnnotation)
|
||||
|
||||
@@ -1498,3 +1774,18 @@ func toFQDN(t *testing.T, s string) dnsname.FQDN {
|
||||
}
|
||||
return fqdn
|
||||
}
|
||||
|
||||
func proxyCreatedCondition(clock tstime.Clock) []metav1.Condition {
|
||||
return []metav1.Condition{{
|
||||
Type: string(tsapi.ProxyReady),
|
||||
Status: metav1.ConditionTrue,
|
||||
ObservedGeneration: 0,
|
||||
LastTransitionTime: conditionTime(clock),
|
||||
Reason: reasonProxyCreated,
|
||||
Message: reasonProxyCreated,
|
||||
}}
|
||||
}
|
||||
|
||||
func conditionTime(clock tstime.Clock) metav1.Time {
|
||||
return metav1.NewTime(clock.Now().Truncate(time.Second))
|
||||
}
|
||||
|
||||
@@ -11,15 +11,19 @@ import (
|
||||
"log"
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
"go.uber.org/zap"
|
||||
"k8s.io/client-go/rest"
|
||||
"k8s.io/client-go/transport"
|
||||
"tailscale.com/client/tailscale"
|
||||
"tailscale.com/client/tailscale/apitype"
|
||||
ksr "tailscale.com/k8s-operator/sessionrecording"
|
||||
"tailscale.com/kube/kubetypes"
|
||||
"tailscale.com/tailcfg"
|
||||
"tailscale.com/tsnet"
|
||||
"tailscale.com/util/clientmetric"
|
||||
@@ -27,12 +31,27 @@ import (
|
||||
"tailscale.com/util/set"
|
||||
)
|
||||
|
||||
var whoIsKey = ctxkey.New("", (*apitype.WhoIsResponse)(nil))
|
||||
|
||||
var counterNumRequestsProxied = clientmetric.NewCounter("k8s_auth_proxy_requests_proxied")
|
||||
var (
|
||||
// counterNumRequestsproxies counts the number of API server requests proxied via this proxy.
|
||||
counterNumRequestsProxied = clientmetric.NewCounter("k8s_auth_proxy_requests_proxied")
|
||||
whoIsKey = ctxkey.New("", (*apitype.WhoIsResponse)(nil))
|
||||
)
|
||||
|
||||
type apiServerProxyMode int
|
||||
|
||||
func (a apiServerProxyMode) String() string {
|
||||
switch a {
|
||||
case apiserverProxyModeDisabled:
|
||||
return "disabled"
|
||||
case apiserverProxyModeEnabled:
|
||||
return "auth"
|
||||
case apiserverProxyModeNoAuth:
|
||||
return "noauth"
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
const (
|
||||
apiserverProxyModeDisabled apiServerProxyMode = iota
|
||||
apiserverProxyModeEnabled
|
||||
@@ -96,26 +115,7 @@ func maybeLaunchAPIServerProxy(zlog *zap.SugaredLogger, restConfig *rest.Config,
|
||||
if err != nil {
|
||||
startlog.Fatalf("could not get rest.TransportConfig(): %v", err)
|
||||
}
|
||||
go runAPIServerProxy(s, rt, zlog.Named("apiserver-proxy"), mode)
|
||||
}
|
||||
|
||||
// apiserverProxy is an http.Handler that authenticates requests using the Tailscale
|
||||
// LocalAPI and then proxies them to the Kubernetes API.
|
||||
type apiserverProxy struct {
|
||||
log *zap.SugaredLogger
|
||||
lc *tailscale.LocalClient
|
||||
rp *httputil.ReverseProxy
|
||||
}
|
||||
|
||||
func (h *apiserverProxy) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
who, err := h.lc.WhoIs(r.Context(), r.RemoteAddr)
|
||||
if err != nil {
|
||||
h.log.Errorf("failed to authenticate caller: %v", err)
|
||||
http.Error(w, "failed to authenticate caller", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
counterNumRequestsProxied.Add(1)
|
||||
h.rp.ServeHTTP(w, r.WithContext(whoIsKey.WithValue(r.Context(), who)))
|
||||
go runAPIServerProxy(s, rt, zlog.Named("apiserver-proxy"), mode, restConfig.Host)
|
||||
}
|
||||
|
||||
// runAPIServerProxy runs an HTTP server that authenticates requests using the
|
||||
@@ -132,64 +132,43 @@ func (h *apiserverProxy) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
// are passed through to the Kubernetes API.
|
||||
//
|
||||
// It never returns.
|
||||
func runAPIServerProxy(s *tsnet.Server, rt http.RoundTripper, log *zap.SugaredLogger, mode apiServerProxyMode) {
|
||||
func runAPIServerProxy(ts *tsnet.Server, rt http.RoundTripper, log *zap.SugaredLogger, mode apiServerProxyMode, host string) {
|
||||
if mode == apiserverProxyModeDisabled {
|
||||
return
|
||||
}
|
||||
ln, err := s.Listen("tcp", ":443")
|
||||
ln, err := ts.Listen("tcp", ":443")
|
||||
if err != nil {
|
||||
log.Fatalf("could not listen on :443: %v", err)
|
||||
}
|
||||
u, err := url.Parse(fmt.Sprintf("https://%s:%s", os.Getenv("KUBERNETES_SERVICE_HOST"), os.Getenv("KUBERNETES_SERVICE_PORT_HTTPS")))
|
||||
u, err := url.Parse(host)
|
||||
if err != nil {
|
||||
log.Fatalf("runAPIServerProxy: failed to parse URL %v", err)
|
||||
}
|
||||
|
||||
lc, err := s.LocalClient()
|
||||
lc, err := ts.LocalClient()
|
||||
if err != nil {
|
||||
log.Fatalf("could not get local client: %v", err)
|
||||
}
|
||||
|
||||
ap := &apiserverProxy{
|
||||
log: log,
|
||||
lc: lc,
|
||||
rp: &httputil.ReverseProxy{
|
||||
Rewrite: func(r *httputil.ProxyRequest) {
|
||||
// Replace the URL with the Kubernetes APIServer.
|
||||
|
||||
r.Out.URL.Scheme = u.Scheme
|
||||
r.Out.URL.Host = u.Host
|
||||
if mode == apiserverProxyModeNoAuth {
|
||||
// If we are not providing authentication, then we are just
|
||||
// proxying to the Kubernetes API, so we don't need to do
|
||||
// anything else.
|
||||
return
|
||||
}
|
||||
|
||||
// We want to proxy to the Kubernetes API, but we want to use
|
||||
// the caller's identity to do so. We do this by impersonating
|
||||
// the caller using the Kubernetes User Impersonation feature:
|
||||
// https://kubernetes.io/docs/reference/access-authn-authz/authentication/#user-impersonation
|
||||
|
||||
// Out of paranoia, remove all authentication headers that might
|
||||
// have been set by the client.
|
||||
r.Out.Header.Del("Authorization")
|
||||
r.Out.Header.Del("Impersonate-Group")
|
||||
r.Out.Header.Del("Impersonate-User")
|
||||
r.Out.Header.Del("Impersonate-Uid")
|
||||
for k := range r.Out.Header {
|
||||
if strings.HasPrefix(k, "Impersonate-Extra-") {
|
||||
r.Out.Header.Del(k)
|
||||
}
|
||||
}
|
||||
|
||||
// Now add the impersonation headers that we want.
|
||||
if err := addImpersonationHeaders(r.Out, log); err != nil {
|
||||
panic("failed to add impersonation headers: " + err.Error())
|
||||
}
|
||||
},
|
||||
Transport: rt,
|
||||
},
|
||||
log: log,
|
||||
lc: lc,
|
||||
mode: mode,
|
||||
upstreamURL: u,
|
||||
ts: ts,
|
||||
}
|
||||
ap.rp = &httputil.ReverseProxy{
|
||||
Rewrite: func(pr *httputil.ProxyRequest) {
|
||||
ap.addImpersonationHeadersAsRequired(pr.Out)
|
||||
},
|
||||
Transport: rt,
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/", ap.serveDefault)
|
||||
mux.HandleFunc("POST /api/v1/namespaces/{namespace}/pods/{pod}/exec", ap.serveExecSPDY)
|
||||
mux.HandleFunc("GET /api/v1/namespaces/{namespace}/pods/{pod}/exec", ap.serveExecWS)
|
||||
|
||||
hs := &http.Server{
|
||||
// Kubernetes uses SPDY for exec and port-forward, however SPDY is
|
||||
// incompatible with HTTP/2; so disable HTTP/2 in the proxy.
|
||||
@@ -198,44 +177,172 @@ func runAPIServerProxy(s *tsnet.Server, rt http.RoundTripper, log *zap.SugaredLo
|
||||
NextProtos: []string{"http/1.1"},
|
||||
},
|
||||
TLSNextProto: make(map[string]func(*http.Server, *tls.Conn, http.Handler)),
|
||||
Handler: ap,
|
||||
Handler: mux,
|
||||
}
|
||||
log.Infof("listening on %s", ln.Addr())
|
||||
log.Infof("API server proxy in %q mode is listening on %s", mode, ln.Addr())
|
||||
if err := hs.ServeTLS(ln, "", ""); err != nil {
|
||||
log.Fatalf("runAPIServerProxy: failed to serve %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// apiserverProxy is an [net/http.Handler] that authenticates requests using the Tailscale
|
||||
// LocalAPI and then proxies them to the Kubernetes API.
|
||||
type apiserverProxy struct {
|
||||
log *zap.SugaredLogger
|
||||
lc *tailscale.LocalClient
|
||||
rp *httputil.ReverseProxy
|
||||
|
||||
mode apiServerProxyMode
|
||||
ts *tsnet.Server
|
||||
upstreamURL *url.URL
|
||||
}
|
||||
|
||||
// serveDefault is the default handler for Kubernetes API server requests.
|
||||
func (ap *apiserverProxy) serveDefault(w http.ResponseWriter, r *http.Request) {
|
||||
who, err := ap.whoIs(r)
|
||||
if err != nil {
|
||||
ap.authError(w, err)
|
||||
return
|
||||
}
|
||||
counterNumRequestsProxied.Add(1)
|
||||
ap.rp.ServeHTTP(w, r.WithContext(whoIsKey.WithValue(r.Context(), who)))
|
||||
}
|
||||
|
||||
// serveExecSPDY serves 'kubectl exec' requests for sessions streamed over SPDY,
|
||||
// optionally configuring the kubectl exec sessions to be recorded.
|
||||
func (ap *apiserverProxy) serveExecSPDY(w http.ResponseWriter, r *http.Request) {
|
||||
ap.execForProto(w, r, ksr.SPDYProtocol)
|
||||
}
|
||||
|
||||
// serveExecWS serves 'kubectl exec' requests for sessions streamed over WebSocket,
|
||||
// optionally configuring the kubectl exec sessions to be recorded.
|
||||
func (ap *apiserverProxy) serveExecWS(w http.ResponseWriter, r *http.Request) {
|
||||
ap.execForProto(w, r, ksr.WSProtocol)
|
||||
}
|
||||
|
||||
func (ap *apiserverProxy) execForProto(w http.ResponseWriter, r *http.Request, proto ksr.Protocol) {
|
||||
const (
|
||||
podNameKey = "pod"
|
||||
namespaceNameKey = "namespace"
|
||||
upgradeHeaderKey = "Upgrade"
|
||||
)
|
||||
|
||||
who, err := ap.whoIs(r)
|
||||
if err != nil {
|
||||
ap.authError(w, err)
|
||||
return
|
||||
}
|
||||
counterNumRequestsProxied.Add(1)
|
||||
failOpen, addrs, err := determineRecorderConfig(who)
|
||||
if err != nil {
|
||||
ap.log.Errorf("error trying to determine whether the 'kubectl exec' session needs to be recorded: %v", err)
|
||||
return
|
||||
}
|
||||
if failOpen && len(addrs) == 0 { // will not record
|
||||
ap.rp.ServeHTTP(w, r.WithContext(whoIsKey.WithValue(r.Context(), who)))
|
||||
return
|
||||
}
|
||||
ksr.CounterSessionRecordingsAttempted.Add(1) // at this point we know that users intended for this session to be recorded
|
||||
if !failOpen && len(addrs) == 0 {
|
||||
msg := "forbidden: 'kubectl exec' session must be recorded, but no recorders are available."
|
||||
ap.log.Error(msg)
|
||||
http.Error(w, msg, http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
wantsHeader := upgradeHeaderForProto[proto]
|
||||
if h := r.Header.Get(upgradeHeaderKey); h != wantsHeader {
|
||||
msg := fmt.Sprintf("[unexpected] unable to verify that streaming protocol is %s, wants Upgrade header %q, got: %q", proto, wantsHeader, h)
|
||||
if failOpen {
|
||||
msg = msg + "; failure mode is 'fail open'; continuing session without recording."
|
||||
ap.log.Warn(msg)
|
||||
ap.rp.ServeHTTP(w, r.WithContext(whoIsKey.WithValue(r.Context(), who)))
|
||||
return
|
||||
}
|
||||
ap.log.Error(msg)
|
||||
msg += "; failure mode is 'fail closed'; closing connection."
|
||||
http.Error(w, msg, http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
opts := ksr.HijackerOpts{
|
||||
Req: r,
|
||||
W: w,
|
||||
Proto: proto,
|
||||
TS: ap.ts,
|
||||
Who: who,
|
||||
Addrs: addrs,
|
||||
FailOpen: failOpen,
|
||||
Pod: r.PathValue(podNameKey),
|
||||
Namespace: r.PathValue(namespaceNameKey),
|
||||
Log: ap.log,
|
||||
}
|
||||
h := ksr.New(opts)
|
||||
|
||||
ap.rp.ServeHTTP(h, r.WithContext(whoIsKey.WithValue(r.Context(), who)))
|
||||
}
|
||||
|
||||
func (h *apiserverProxy) addImpersonationHeadersAsRequired(r *http.Request) {
|
||||
r.URL.Scheme = h.upstreamURL.Scheme
|
||||
r.URL.Host = h.upstreamURL.Host
|
||||
if h.mode == apiserverProxyModeNoAuth {
|
||||
// If we are not providing authentication, then we are just
|
||||
// proxying to the Kubernetes API, so we don't need to do
|
||||
// anything else.
|
||||
return
|
||||
}
|
||||
|
||||
// We want to proxy to the Kubernetes API, but we want to use
|
||||
// the caller's identity to do so. We do this by impersonating
|
||||
// the caller using the Kubernetes User Impersonation feature:
|
||||
// https://kubernetes.io/docs/reference/access-authn-authz/authentication/#user-impersonation
|
||||
|
||||
// Out of paranoia, remove all authentication headers that might
|
||||
// have been set by the client.
|
||||
r.Header.Del("Authorization")
|
||||
r.Header.Del("Impersonate-Group")
|
||||
r.Header.Del("Impersonate-User")
|
||||
r.Header.Del("Impersonate-Uid")
|
||||
for k := range r.Header {
|
||||
if strings.HasPrefix(k, "Impersonate-Extra-") {
|
||||
r.Header.Del(k)
|
||||
}
|
||||
}
|
||||
|
||||
// Now add the impersonation headers that we want.
|
||||
if err := addImpersonationHeaders(r, h.log); err != nil {
|
||||
log.Printf("failed to add impersonation headers: " + err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func (ap *apiserverProxy) whoIs(r *http.Request) (*apitype.WhoIsResponse, error) {
|
||||
return ap.lc.WhoIs(r.Context(), r.RemoteAddr)
|
||||
}
|
||||
|
||||
func (ap *apiserverProxy) authError(w http.ResponseWriter, err error) {
|
||||
ap.log.Errorf("failed to authenticate caller: %v", err)
|
||||
http.Error(w, "failed to authenticate caller", http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
const (
|
||||
// oldCapabilityName is a legacy form of
|
||||
// tailfcg.PeerCapabilityKubernetes capability. The only capability rule
|
||||
// that is respected for this form is group impersonation - for
|
||||
// backwards compatibility reasons.
|
||||
// TODO (irbekrm): determine if anyone uses this and remove if possible.
|
||||
oldCapabilityName = "https://" + tailcfg.PeerCapabilityKubernetes
|
||||
)
|
||||
|
||||
type capRule struct {
|
||||
// Impersonate is a list of rules that specify how to impersonate the caller
|
||||
// when proxying to the Kubernetes API.
|
||||
Impersonate *impersonateRule `json:"impersonate,omitempty"`
|
||||
}
|
||||
|
||||
// TODO(maisem): move this to some well-known location so that it can be shared
|
||||
// with control.
|
||||
type impersonateRule struct {
|
||||
Groups []string `json:"groups,omitempty"`
|
||||
}
|
||||
|
||||
// addImpersonationHeaders adds the appropriate headers to r to impersonate the
|
||||
// caller when proxying to the Kubernetes API. It uses the WhoIsResponse stashed
|
||||
// in the context by the apiserverProxy.
|
||||
func addImpersonationHeaders(r *http.Request, log *zap.SugaredLogger) error {
|
||||
log = log.With("remote", r.RemoteAddr)
|
||||
who := whoIsKey.Value(r.Context())
|
||||
rules, err := tailcfg.UnmarshalCapJSON[capRule](who.CapMap, tailcfg.PeerCapabilityKubernetes)
|
||||
rules, err := tailcfg.UnmarshalCapJSON[kubetypes.KubernetesCapRule](who.CapMap, tailcfg.PeerCapabilityKubernetes)
|
||||
if len(rules) == 0 && err == nil {
|
||||
// Try the old capability name for backwards compatibility.
|
||||
rules, err = tailcfg.UnmarshalCapJSON[capRule](who.CapMap, oldCapabilityName)
|
||||
rules, err = tailcfg.UnmarshalCapJSON[kubetypes.KubernetesCapRule](who.CapMap, oldCapabilityName)
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to unmarshal capability: %v", err)
|
||||
@@ -276,3 +383,39 @@ func addImpersonationHeaders(r *http.Request, log *zap.SugaredLogger) error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// determineRecorderConfig determines recorder config from requester's peer
|
||||
// capabilities. Determines whether a 'kubectl exec' session from this requester
|
||||
// needs to be recorded and what recorders the recording should be sent to.
|
||||
func determineRecorderConfig(who *apitype.WhoIsResponse) (failOpen bool, recorderAddresses []netip.AddrPort, _ error) {
|
||||
if who == nil {
|
||||
return false, nil, errors.New("[unexpected] cannot determine caller")
|
||||
}
|
||||
failOpen = true
|
||||
rules, err := tailcfg.UnmarshalCapJSON[kubetypes.KubernetesCapRule](who.CapMap, tailcfg.PeerCapabilityKubernetes)
|
||||
if err != nil {
|
||||
return failOpen, nil, fmt.Errorf("failed to unmarshal Kubernetes capability: %w", err)
|
||||
}
|
||||
if len(rules) == 0 {
|
||||
return failOpen, nil, nil
|
||||
}
|
||||
|
||||
for _, rule := range rules {
|
||||
if len(rule.RecorderAddrs) != 0 {
|
||||
// TODO (irbekrm): here or later determine if the
|
||||
// recorders behind those addrs are online - else we
|
||||
// spend 30s trying to reach a recorder whose tailscale
|
||||
// status is offline.
|
||||
recorderAddresses = append(recorderAddresses, rule.RecorderAddrs...)
|
||||
}
|
||||
if rule.EnforceRecorder {
|
||||
failOpen = false
|
||||
}
|
||||
}
|
||||
return failOpen, recorderAddresses, nil
|
||||
}
|
||||
|
||||
var upgradeHeaderForProto = map[ksr.Protocol]string{
|
||||
ksr.SPDYProtocol: "SPDY/3.1",
|
||||
ksr.WSProtocol: "websocket",
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user