Compare commits
3060 Commits
aaron/logl
...
dgentry-co
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2a9a470a80 | ||
|
|
f398712c00 | ||
|
|
d9081d6ba2 | ||
|
|
5347e6a292 | ||
|
|
68da15516f | ||
|
|
70f9c8a6ed | ||
|
|
eced054796 | ||
|
|
1df2d14c8f | ||
|
|
6ada33db77 | ||
|
|
25b6974219 | ||
|
|
b4247fabec | ||
|
|
7e933a8816 | ||
|
|
02908a2d8d | ||
|
|
469b7cabad | ||
|
|
7a3ae39025 | ||
|
|
35376d52d4 | ||
|
|
f09cb45f9d | ||
|
|
73bbf941f8 | ||
|
|
09b5bb3e55 | ||
|
|
891d964bd4 | ||
|
|
d603d18956 | ||
|
|
cf27761265 | ||
|
|
cb00eac850 | ||
|
|
674beabc73 | ||
|
|
afb72ecd73 | ||
|
|
851536044a | ||
|
|
c3a8e63100 | ||
|
|
b47cf04624 | ||
|
|
a8fbe284b2 | ||
|
|
756a4c43b6 | ||
|
|
3f27087e9d | ||
|
|
7971333603 | ||
|
|
77127a2494 | ||
|
|
c27870e160 | ||
|
|
c2a551469c | ||
|
|
33bb2bbfe9 | ||
|
|
cac290da87 | ||
|
|
ddb2a6eb8d | ||
|
|
f53c3be07c | ||
|
|
1fc3573446 | ||
|
|
6ca8650c7b | ||
|
|
4dec0c6eb9 | ||
|
|
e6ab7d3c14 | ||
|
|
9d3c6bf52e | ||
|
|
4899c2c1f4 | ||
|
|
b949e208bb | ||
|
|
18bd98d35b | ||
|
|
71271e41d6 | ||
|
|
95faefd1f6 | ||
|
|
8a5b02133d | ||
|
|
51078b6486 | ||
|
|
7fd6cc3caa | ||
|
|
feabb34ea0 | ||
|
|
e06f2f1873 | ||
|
|
97ee3891f1 | ||
|
|
56ebcd1ed4 | ||
|
|
e89927de2b | ||
|
|
18e2936d25 | ||
|
|
c363b9055d | ||
|
|
a6270826a3 | ||
|
|
5297bd2cff | ||
|
|
5c555cdcbb | ||
|
|
8c7169105e | ||
|
|
9cb6c5bb78 | ||
|
|
af5a586463 | ||
|
|
754fb9a8a8 | ||
|
|
8f948638c5 | ||
|
|
b1867eb23f | ||
|
|
24f322bc43 | ||
|
|
1a78f240b5 | ||
|
|
7783a960e8 | ||
|
|
ce0830837d | ||
|
|
37c646d9d3 | ||
|
|
1294b89792 | ||
|
|
2d4f808a4c | ||
|
|
4abd470322 | ||
|
|
96f01a73b1 | ||
|
|
d62af8e643 | ||
|
|
1cb9e33a95 | ||
|
|
c1ef55249a | ||
|
|
319607625f | ||
|
|
9d96e05267 | ||
|
|
8b630c91bc | ||
|
|
0a412eba40 | ||
|
|
11348fbe72 | ||
|
|
fbfee6a8c0 | ||
|
|
7a0de2997e | ||
|
|
aad3584319 | ||
|
|
fffafc65d6 | ||
|
|
9f05018419 | ||
|
|
04a8b8bb8e | ||
|
|
4e083e4548 | ||
|
|
78a083e144 | ||
|
|
05a1f5bf71 | ||
|
|
56c0a75ea9 | ||
|
|
ba6ec42f6d | ||
|
|
677d486830 | ||
|
|
7f08bddfe1 | ||
|
|
00977f6de9 | ||
|
|
0ccfcb515c | ||
|
|
3749a3bbbb | ||
|
|
6b1ed732df | ||
|
|
70de16bda7 | ||
|
|
7f540042d5 | ||
|
|
d0b8bdf8f7 | ||
|
|
9eedf86563 | ||
|
|
249edaa349 | ||
|
|
893bdd729c | ||
|
|
b4e587c3bd | ||
|
|
9593cd3871 | ||
|
|
623926a25d | ||
|
|
886917c42b | ||
|
|
553f657248 | ||
|
|
6f36f8842c | ||
|
|
13767e5108 | ||
|
|
f991c8a61f | ||
|
|
498f7ec663 | ||
|
|
e4cb83b18b | ||
|
|
e6aa7b815d | ||
|
|
b7988b3825 | ||
|
|
557ddced6c | ||
|
|
c761d102ea | ||
|
|
559f560d2d | ||
|
|
c42398b5b7 | ||
|
|
3ee756757b | ||
|
|
dc1c7cbe3e | ||
|
|
3befc0ef02 | ||
|
|
7868393200 | ||
|
|
b4816e19b6 | ||
|
|
da1b917575 | ||
|
|
52e4f24c58 | ||
|
|
b29047bcf0 | ||
|
|
e499a6bae8 | ||
|
|
93c6e1d53b | ||
|
|
91b9899402 | ||
|
|
730cdfc1f7 | ||
|
|
3655fb3ba0 | ||
|
|
5902d51ba4 | ||
|
|
286c6ce27c | ||
|
|
eb22c0dfc7 | ||
|
|
efac2cb8d6 | ||
|
|
b775a3799e | ||
|
|
73e53dcd1c | ||
|
|
5efd5e093e | ||
|
|
6cbd002eda | ||
|
|
656a77ab4e | ||
|
|
c26d91d6bd | ||
|
|
4130851f12 | ||
|
|
67926ede39 | ||
|
|
425cf9aa9d | ||
|
|
5f5c9142cc | ||
|
|
72e53749c1 | ||
|
|
d2ea9bb1eb | ||
|
|
ab810f1f6d | ||
|
|
e03f0d5f5c | ||
|
|
a56e58c244 | ||
|
|
324f0d5f80 | ||
|
|
ee90cd02fd | ||
|
|
e91e96dfa5 | ||
|
|
41b05e6910 | ||
|
|
db9c0d0a63 | ||
|
|
16fa3c24ea | ||
|
|
a74970305b | ||
|
|
8833dc51f1 | ||
|
|
0c8c374a41 | ||
|
|
84acf83019 | ||
|
|
87bc831730 | ||
|
|
71f2c67c6b | ||
|
|
aae1a28a2b | ||
|
|
32c0156311 | ||
|
|
d71184d674 | ||
|
|
246e0ccdca | ||
|
|
4823a7e591 | ||
|
|
856d32b4a9 | ||
|
|
2a7b3ada58 | ||
|
|
f50b2a87ec | ||
|
|
b5b4298325 | ||
|
|
2c92f94e2a | ||
|
|
5429ee2566 | ||
|
|
5b3f5eabb5 | ||
|
|
2c0f0ee759 | ||
|
|
5d62b17cc5 | ||
|
|
354455e8be | ||
|
|
5c2b2fa1f8 | ||
|
|
ca4396107e | ||
|
|
80206b5323 | ||
|
|
2066f9fbb2 | ||
|
|
697f92f4a7 | ||
|
|
d31460f793 | ||
|
|
3e298e9380 | ||
|
|
0275afa0c6 | ||
|
|
e3d6236606 | ||
|
|
c608660d12 | ||
|
|
578b357849 | ||
|
|
bdd9eeca90 | ||
|
|
651620623b | ||
|
|
530aaa52f1 | ||
|
|
098d110746 | ||
|
|
7aed9712d8 | ||
|
|
04fabcd359 | ||
|
|
75dbd71f49 | ||
|
|
241c983920 | ||
|
|
3b32d6c679 | ||
|
|
08302c0731 | ||
|
|
6cc5b272d8 | ||
|
|
059051c58a | ||
|
|
fb2f3e4741 | ||
|
|
81e8335e23 | ||
|
|
b83804cc82 | ||
|
|
36242904f1 | ||
|
|
a82a74f2cf | ||
|
|
c5006f143f | ||
|
|
ea6ca78963 | ||
|
|
5473d11caa | ||
|
|
65dc711c76 | ||
|
|
95635857dc | ||
|
|
a5ae21a832 | ||
|
|
4c793014af | ||
|
|
055f3fd843 | ||
|
|
bb3d338334 | ||
|
|
1c88a77f68 | ||
|
|
6e6a510001 | ||
|
|
4669e7f7d5 | ||
|
|
546506a54d | ||
|
|
ae89482f25 | ||
|
|
c5b2a365de | ||
|
|
5f4d76c18c | ||
|
|
ea9dd8fabc | ||
|
|
d52ab181c3 | ||
|
|
c7ce4e07e5 | ||
|
|
3056a98bbd | ||
|
|
ed50f360db | ||
|
|
4232826cce | ||
|
|
652f77d236 | ||
|
|
35ad2aafe3 | ||
|
|
1166765559 | ||
|
|
c08cf2a9c6 | ||
|
|
d9ae7d670e | ||
|
|
19a9d9037f | ||
|
|
4da0689c2c | ||
|
|
d06b48dd0a | ||
|
|
258f16f84b | ||
|
|
0d991249e1 | ||
|
|
d25217c9db | ||
|
|
98b5da47e8 | ||
|
|
a61caea911 | ||
|
|
3d37328af6 | ||
|
|
db2f37d7c6 | ||
|
|
9538e9f970 | ||
|
|
926c990a09 | ||
|
|
fb5ceb03e3 | ||
|
|
0f3c279b86 | ||
|
|
760b945bc0 | ||
|
|
8ab46952d4 | ||
|
|
f6845b10f6 | ||
|
|
e7727db553 | ||
|
|
335a5aaf9a | ||
|
|
4c693d2ee8 | ||
|
|
8428a64b56 | ||
|
|
1858ad65c8 | ||
|
|
85155ddaf3 | ||
|
|
dfefaa5e35 | ||
|
|
f3a5bfb1b9 | ||
|
|
7ce1c6f981 | ||
|
|
3421784e37 | ||
|
|
6e66e5beeb | ||
|
|
99bb355791 | ||
|
|
9843e922b8 | ||
|
|
82c1dd8732 | ||
|
|
3c276d7de2 | ||
|
|
67396d716b | ||
|
|
b8a4c96c53 | ||
|
|
727b1432a8 | ||
|
|
ad4c11aca1 | ||
|
|
45eafe1b06 | ||
|
|
eb9f1db269 | ||
|
|
343c0f1031 | ||
|
|
47ffbffa97 | ||
|
|
39ade4d0d4 | ||
|
|
9203916a4a | ||
|
|
3af051ea27 | ||
|
|
c0ade132e6 | ||
|
|
668a0dd5ab | ||
|
|
9ee173c256 | ||
|
|
7c1ed38ab3 | ||
|
|
12d4685328 | ||
|
|
ff6fadddb6 | ||
|
|
f06e64c562 | ||
|
|
42072683d6 | ||
|
|
4e91cf20a8 | ||
|
|
d050700a3b | ||
|
|
683ba62f3e | ||
|
|
0396366aae | ||
|
|
70ea073478 | ||
|
|
a5ffd5e7c3 | ||
|
|
9a86aa5732 | ||
|
|
f12c71e71c | ||
|
|
dc7aa98b76 | ||
|
|
d506a55c8a | ||
|
|
60e9bd6047 | ||
|
|
db307d35e1 | ||
|
|
95082a8dde | ||
|
|
d23b8ffb13 | ||
|
|
1073b56e18 | ||
|
|
1eadb2b608 | ||
|
|
4a38d8d372 | ||
|
|
0dc65b2e47 | ||
|
|
1383fc57ad | ||
|
|
0a0adb68ad | ||
|
|
a1d4144b18 | ||
|
|
8452d273e3 | ||
|
|
0909e90890 | ||
|
|
472eb6f6f5 | ||
|
|
18b2638b07 | ||
|
|
70a9854b39 | ||
|
|
5ee349e075 | ||
|
|
1bd3edbb46 | ||
|
|
50990f8931 | ||
|
|
96094cc07e | ||
|
|
6fd1961cd7 | ||
|
|
51d3220153 | ||
|
|
96c2cd2ada | ||
|
|
c2241248c8 | ||
|
|
ac7b4d62fd | ||
|
|
d413dd7ee5 | ||
|
|
d61494db68 | ||
|
|
9a56184bef | ||
|
|
86b0fc5295 | ||
|
|
7686ff6c46 | ||
|
|
7d60c19d7d | ||
|
|
f6a203fe23 | ||
|
|
45eeef244e | ||
|
|
cb3b281e98 | ||
|
|
a4aa6507fa | ||
|
|
7175f06e62 | ||
|
|
f824274093 | ||
|
|
3280c81c95 | ||
|
|
0f397baf77 | ||
|
|
52a19b5970 | ||
|
|
6bc15f3a73 | ||
|
|
1262df0578 | ||
|
|
8683ce78c2 | ||
|
|
d06a75dcd0 | ||
|
|
c6fadd6d71 | ||
|
|
9a3bc9049c | ||
|
|
34e3450734 | ||
|
|
055fdb235f | ||
|
|
e1fbb5457b | ||
|
|
003e4aff71 | ||
|
|
0c1e3ff625 | ||
|
|
9cbec4519b | ||
|
|
8b3ea13af0 | ||
|
|
f7b7ccf835 | ||
|
|
346445acdd | ||
|
|
96277b63ff | ||
|
|
f52273767f | ||
|
|
959362a1f4 | ||
|
|
1f12b3aedc | ||
|
|
7074a40c06 | ||
|
|
86dc0af5ae | ||
|
|
61ae16cb6f | ||
|
|
47cf836720 | ||
|
|
21247f766f | ||
|
|
04e1ce0034 | ||
|
|
ecc1d6907b | ||
|
|
77060c4d89 | ||
|
|
4e72992900 | ||
|
|
ce1e02096a | ||
|
|
c621141746 | ||
|
|
313a129fe5 | ||
|
|
37eab31f68 | ||
|
|
f5bfdefa00 | ||
|
|
7053e19562 | ||
|
|
794650fe50 | ||
|
|
be9914f714 | ||
|
|
9ce1f5c7d2 | ||
|
|
306b85b9a3 | ||
|
|
0a74d46568 | ||
|
|
14320290c3 | ||
|
|
17438a98c0 | ||
|
|
29a35d4a5d | ||
|
|
fe709c81e5 | ||
|
|
ae747a2e48 | ||
|
|
b90b9b4653 | ||
|
|
7538f38671 | ||
|
|
abfe5d3879 | ||
|
|
8b492b4121 | ||
|
|
e952564b59 | ||
|
|
1cd03bc0a1 | ||
|
|
da6eb076aa | ||
|
|
c919ff540f | ||
|
|
930e6f68f2 | ||
|
|
11ece02f52 | ||
|
|
6dfa403e6b | ||
|
|
7aea219a0f | ||
|
|
6b882a1511 | ||
|
|
3bce9632d9 | ||
|
|
8ba07aac85 | ||
|
|
55bb7314f2 | ||
|
|
a64593d7ef | ||
|
|
590c693b96 | ||
|
|
a79b1d23b8 | ||
|
|
67e48d9285 | ||
|
|
8d2eaa1956 | ||
|
|
0c6fe94cf4 | ||
|
|
f92e6a1be8 | ||
|
|
fcbb2bf348 | ||
|
|
346dc5f37e | ||
|
|
d74c771fda | ||
|
|
be5bd1e619 | ||
|
|
18d9c92342 | ||
|
|
c86a610eb3 | ||
|
|
3451b89e5f | ||
|
|
ce4bf41dcf | ||
|
|
4af22f3785 | ||
|
|
e7d1538a2d | ||
|
|
b407fdef70 | ||
|
|
fe91160775 | ||
|
|
e80ba4ce79 | ||
|
|
9430481926 | ||
|
|
ce5909dafc | ||
|
|
4828e4c2db | ||
|
|
7b18ed293b | ||
|
|
6b6a8cf843 | ||
|
|
535db01b3f | ||
|
|
c8dea67cbf | ||
|
|
320f77bd24 | ||
|
|
12ac672542 | ||
|
|
24d41e4ae7 | ||
|
|
98a5116434 | ||
|
|
de9ba1c621 | ||
|
|
f3077c6ab5 | ||
|
|
3b7ebeba2e | ||
|
|
b42c4e2da1 | ||
|
|
dc8287ab3b | ||
|
|
0c3d343ea3 | ||
|
|
05486f0f8e | ||
|
|
ff7f4b4224 | ||
|
|
a61a9ab087 | ||
|
|
d45af7c66f | ||
|
|
5fb1695bcb | ||
|
|
349c05d38d | ||
|
|
824cd02d6d | ||
|
|
46b0c9168f | ||
|
|
7825074444 | ||
|
|
5b6a90fb33 | ||
|
|
a5dcc4c87b | ||
|
|
d58ba59fd5 | ||
|
|
e881c1caec | ||
|
|
9ea3942b1a | ||
|
|
f61dd12f05 | ||
|
|
9c07f4f512 | ||
|
|
1b8a538953 | ||
|
|
776f9b5875 | ||
|
|
ad9b711a1b | ||
|
|
ea4425d8a9 | ||
|
|
74388a771f | ||
|
|
9089efea06 | ||
|
|
78f087aa02 | ||
|
|
5cfa85e604 | ||
|
|
09068f6c16 | ||
|
|
836f932ead | ||
|
|
7f6bc52b78 | ||
|
|
cf45d6a275 | ||
|
|
05523bdcdd | ||
|
|
e1c7e9b736 | ||
|
|
5edb39d032 | ||
|
|
7c9c68feed | ||
|
|
ea693eacb6 | ||
|
|
3a652d7761 | ||
|
|
7364c6beec | ||
|
|
4b13e6e087 | ||
|
|
5ebff95a4c | ||
|
|
000c0a70f6 | ||
|
|
0df5507c81 | ||
|
|
3722b05465 | ||
|
|
09e5e68297 | ||
|
|
947def7688 | ||
|
|
50b558de74 | ||
|
|
db017d3b12 | ||
|
|
a3b0654ed8 | ||
|
|
35ff5bf5a6 | ||
|
|
cb4a61f951 | ||
|
|
a461d230db | ||
|
|
0fb95ec07d | ||
|
|
84b94b3146 | ||
|
|
699f9699ca | ||
|
|
f6615931d7 | ||
|
|
077bbb8403 | ||
|
|
77ff705545 | ||
|
|
b5ff68a968 | ||
|
|
1b223566dd | ||
|
|
c85d7c301a | ||
|
|
f486041fd1 | ||
|
|
c15997511d | ||
|
|
165f0116f1 | ||
|
|
21170fb175 | ||
|
|
2548496cef | ||
|
|
8a5ec72c85 | ||
|
|
4511e7d64e | ||
|
|
d483ed7774 | ||
|
|
282dad1b62 | ||
|
|
d8191a9813 | ||
|
|
f35ff84ee2 | ||
|
|
93a806ba31 | ||
|
|
7dec09d169 | ||
|
|
02b47d123f | ||
|
|
58a4fd43d8 | ||
|
|
b040094b90 | ||
|
|
d4586ca75f | ||
|
|
93cab56277 | ||
|
|
6e57dee7eb | ||
|
|
261cc498d3 | ||
|
|
af2e4909b6 | ||
|
|
86ad1ea60e | ||
|
|
72d2122cad | ||
|
|
121d1d002c | ||
|
|
25663b1307 | ||
|
|
e92adfe5e4 | ||
|
|
bc0eb6b914 | ||
|
|
e8551d6b40 | ||
|
|
e8d140654a | ||
|
|
7e15c78a5a | ||
|
|
239ad57446 | ||
|
|
24509f8b22 | ||
|
|
0913ec023b | ||
|
|
b090d61c0f | ||
|
|
57da1f1501 | ||
|
|
37c0b9be63 | ||
|
|
18280ebf7d | ||
|
|
623d72c83b | ||
|
|
f101a75dce | ||
|
|
f75a36f9bc | ||
|
|
cf31b58ed1 | ||
|
|
6c791f7d60 | ||
|
|
7ed3681cbe | ||
|
|
95d776bd8c | ||
|
|
9c4364e0b7 | ||
|
|
ddba4824c4 | ||
|
|
bd02d00608 | ||
|
|
25a8daf405 | ||
|
|
17ce75347c | ||
|
|
1a64166073 | ||
|
|
0052830c64 | ||
|
|
8e63d75018 | ||
|
|
c17a817769 | ||
|
|
411e3364a9 | ||
|
|
12238dab48 | ||
|
|
b07347640c | ||
|
|
1fcae42055 | ||
|
|
2398993804 | ||
|
|
4940a718a1 | ||
|
|
9e24a6508a | ||
|
|
c40d095c35 | ||
|
|
a1b8d703d6 | ||
|
|
cc3caa4b2a | ||
|
|
de8e55fda6 | ||
|
|
d5ac18d2c4 | ||
|
|
21e32b23f7 | ||
|
|
3f12b9c8b2 | ||
|
|
98ec8924c2 | ||
|
|
92fc9a01fa | ||
|
|
99e06d3544 | ||
|
|
16bc9350e3 | ||
|
|
215480a022 | ||
|
|
53c722924b | ||
|
|
d16946854f | ||
|
|
7a5263e6d0 | ||
|
|
3d56cafd7d | ||
|
|
6ee85ba412 | ||
|
|
2bc98abbd9 | ||
|
|
7815fbe17a | ||
|
|
90081a25ca | ||
|
|
3d2e35c053 | ||
|
|
f9066ac1f4 | ||
|
|
69f1324c9e | ||
|
|
b3618c23bf | ||
|
|
be4eb6a39e | ||
|
|
66f27c4beb | ||
|
|
682fd72f7b | ||
|
|
3e255d76e1 | ||
|
|
500b9579d5 | ||
|
|
734928d3cb | ||
|
|
6aaf1d48df | ||
|
|
ae63c51ff1 | ||
|
|
17ed2da94d | ||
|
|
82454b57dd | ||
|
|
25a7204bb4 | ||
|
|
49896cbdfa | ||
|
|
c56e94af2d | ||
|
|
a3f11e7710 | ||
|
|
10acc06389 | ||
|
|
a17c45fd6e | ||
|
|
a8e32f1a4b | ||
|
|
371e1ebf07 | ||
|
|
eb6883bb5a | ||
|
|
37925b3e7a | ||
|
|
301e59f398 | ||
|
|
ab7749aed7 | ||
|
|
f57cc19ba2 | ||
|
|
b4c1f039b6 | ||
|
|
c3b979a176 | ||
|
|
34bfd7b419 | ||
|
|
66e46bf501 | ||
|
|
6d65c04987 | ||
|
|
767e839db5 | ||
|
|
7adf15f90e | ||
|
|
ec9213a627 | ||
|
|
eef15b4ffc | ||
|
|
ed46442cb1 | ||
|
|
5ebb271322 | ||
|
|
058d427fa6 | ||
|
|
68f8e5678e | ||
|
|
0554deb48c | ||
|
|
6114247d0a | ||
|
|
52212f4323 | ||
|
|
90a7d3066c | ||
|
|
2315bf246a | ||
|
|
c1ecae13ab | ||
|
|
aa37be70cf | ||
|
|
35bdbeda9f | ||
|
|
9d89e85db7 | ||
|
|
84777354a0 | ||
|
|
9a76deb4b0 | ||
|
|
cde37f5307 | ||
|
|
f7016d8c00 | ||
|
|
c2831f6614 | ||
|
|
9edb848505 | ||
|
|
1ecc16da5f | ||
|
|
306deea03a | ||
|
|
6afffece8a | ||
|
|
4f14ed2ad6 | ||
|
|
f1cd67488d | ||
|
|
44ad7b3746 | ||
|
|
125b982ba5 | ||
|
|
b76d8a88ae | ||
|
|
b242e2c2cb | ||
|
|
8478358d77 | ||
|
|
de5c6ed4be | ||
|
|
736a44264f | ||
|
|
1e6f0bb608 | ||
|
|
aaca911904 | ||
|
|
b145a22f55 | ||
|
|
9cc3f7a3d6 | ||
|
|
ac657caaf1 | ||
|
|
fcf4d044fa | ||
|
|
486195edf0 | ||
|
|
45b5d0983c | ||
|
|
4c05d43008 | ||
|
|
894b237a70 | ||
|
|
f1cc8ab3f9 | ||
|
|
2a6c237d4c | ||
|
|
453620dca1 | ||
|
|
41db1d7bba | ||
|
|
907c56c200 | ||
|
|
e1bcecc393 | ||
|
|
bb4b35e923 | ||
|
|
88cc0ad9f7 | ||
|
|
7560435eb5 | ||
|
|
32d486e2bf | ||
|
|
3c53bedbbf | ||
|
|
388b124513 | ||
|
|
efd6d90dd7 | ||
|
|
3f6b0d8c84 | ||
|
|
bec9815f02 | ||
|
|
486ab427b4 | ||
|
|
7c04846eac | ||
|
|
9ab70212f4 | ||
|
|
6b56e92acc | ||
|
|
a3c7b21cd1 | ||
|
|
abcb7ec1ce | ||
|
|
2c782d742c | ||
|
|
24f0e91169 | ||
|
|
1138f4eb5f | ||
|
|
9b5e29761c | ||
|
|
8bdc03913c | ||
|
|
3304819739 | ||
|
|
9101fabdf8 | ||
|
|
94a51bdd62 | ||
|
|
f8b0caa8c2 | ||
|
|
c19b5bfbc3 | ||
|
|
0573f6e953 | ||
|
|
60e5761d60 | ||
|
|
7aba0b0d78 | ||
|
|
7a82fd8dbe | ||
|
|
354885a08d | ||
|
|
4f95b6966b | ||
|
|
c95de4c7a8 | ||
|
|
3d70fecde4 | ||
|
|
96d7af3469 | ||
|
|
8cda647a0f | ||
|
|
49015b00fe | ||
|
|
2bbedd2001 | ||
|
|
60ab8089ff | ||
|
|
cd313e410b | ||
|
|
8c0572e088 | ||
|
|
a7648a6723 | ||
|
|
ffaa6be8a4 | ||
|
|
7b1c3dfd28 | ||
|
|
f05a9f3e7f | ||
|
|
339397ab74 | ||
|
|
9d1a3a995c | ||
|
|
92fb80d55f | ||
|
|
28ee355c56 | ||
|
|
cd4c71c122 | ||
|
|
fd8c8a3700 | ||
|
|
3f1f906b63 | ||
|
|
cb53846717 | ||
|
|
0c427f23bd | ||
|
|
4d94d72fba | ||
|
|
0a86705d59 | ||
|
|
a795b4a641 | ||
|
|
6ebd87c669 | ||
|
|
1ca5dcce15 | ||
|
|
2e4e7d6b9d | ||
|
|
79ee6d6e1e | ||
|
|
2e19790f61 | ||
|
|
e42be5a060 | ||
|
|
075abd8ec1 | ||
|
|
12a2221db2 | ||
|
|
97ee0bc685 | ||
|
|
b0a984dc26 | ||
|
|
626f650033 | ||
|
|
d4413f723d | ||
|
|
cafd9a2bec | ||
|
|
ab310a7f60 | ||
|
|
d9eca20ee2 | ||
|
|
243ce6ccc1 | ||
|
|
9c64e015e5 | ||
|
|
832f1028c7 | ||
|
|
a874f1afd8 | ||
|
|
e26376194d | ||
|
|
77f56794c9 | ||
|
|
1377618dbc | ||
|
|
8e840489ed | ||
|
|
2cf6e12790 | ||
|
|
c11af12a49 | ||
|
|
ba41d14320 | ||
|
|
1f57088cbd | ||
|
|
3417ddc00c | ||
|
|
2a9817da39 | ||
|
|
bfe5623a86 | ||
|
|
4a58b1c293 | ||
|
|
7c1068b7ac | ||
|
|
fbacc0bd39 | ||
|
|
8b80d63b42 | ||
|
|
61886e031e | ||
|
|
d4de60c3ae | ||
|
|
30d9201a11 | ||
|
|
32b8f25ed1 | ||
|
|
6829caf6de | ||
|
|
e48c0bf0e7 | ||
|
|
f314fa4a4a | ||
|
|
dc5bc32d8f | ||
|
|
6697690b55 | ||
|
|
a2153afeeb | ||
|
|
0f5090c526 | ||
|
|
88097b836a | ||
|
|
2ae670eb71 | ||
|
|
0ed088b47b | ||
|
|
909e9eabe4 | ||
|
|
b6d20e6f8f | ||
|
|
1302295299 | ||
|
|
c6794dec11 | ||
|
|
c783f28228 | ||
|
|
c1cbd41fdc | ||
|
|
e1cdcf7708 | ||
|
|
80692edcb8 | ||
|
|
27a0f0a55b | ||
|
|
99f17a7135 | ||
|
|
4dda949760 | ||
|
|
a076213f58 | ||
|
|
4451a7c364 | ||
|
|
fe95d81b43 | ||
|
|
5b110685fb | ||
|
|
0b3b81b37a | ||
|
|
6172f9590b | ||
|
|
1543e233e6 | ||
|
|
167e154bcc | ||
|
|
67e912824a | ||
|
|
63b1a4e35d | ||
|
|
f077b672e4 | ||
|
|
2e0aa151c9 | ||
|
|
62130e6b68 | ||
|
|
2a9d46c38f | ||
|
|
eefee6f149 | ||
|
|
699996ad6c | ||
|
|
12f8c98823 | ||
|
|
1c4a047ad0 | ||
|
|
f8f0b981ac | ||
|
|
a353ae079b | ||
|
|
43e230d4cd | ||
|
|
5dd0b02133 | ||
|
|
d3c8c3dd00 | ||
|
|
64f16f7f38 | ||
|
|
6554a0cbec | ||
|
|
d17312265e | ||
|
|
4321d1d6e9 | ||
|
|
2492ca2900 | ||
|
|
570cb018da | ||
|
|
dc1d8826a2 | ||
|
|
67882ad35d | ||
|
|
07eacdfe92 | ||
|
|
d06fac0ede | ||
|
|
9d09c821f7 | ||
|
|
2aa8299c37 | ||
|
|
88ee857bc8 | ||
|
|
1a691ec5b2 | ||
|
|
6a156f6243 | ||
|
|
525b9c806f | ||
|
|
fc5b137d25 | ||
|
|
32e0ba5e68 | ||
|
|
399a80785e | ||
|
|
c0b4a54146 | ||
|
|
c4fe9c536d | ||
|
|
370b2c37e0 | ||
|
|
cb94ddb7b8 | ||
|
|
66f97f4bea | ||
|
|
e32e5c0d0c | ||
|
|
3d180a16c3 | ||
|
|
4e86857313 | ||
|
|
745ee97973 | ||
|
|
a4fd4fd845 | ||
|
|
e3cb982139 | ||
|
|
5ae786988c | ||
|
|
0ca8bf1e26 | ||
|
|
03e848e3b5 | ||
|
|
7c88eeba86 | ||
|
|
f0ee03dfaf | ||
|
|
4664318be2 | ||
|
|
678bb92bb8 | ||
|
|
9b6e48658f | ||
|
|
85215ed58a | ||
|
|
b69059334b | ||
|
|
84c99fe0d9 | ||
|
|
da90fab899 | ||
|
|
ca49b29582 | ||
|
|
cb2fd5be92 | ||
|
|
d27a6e1c53 | ||
|
|
4f454f4122 | ||
|
|
ea84fc9ad2 | ||
|
|
1ce08256c0 | ||
|
|
827abbeeaa | ||
|
|
d1ecb1f43b | ||
|
|
a743b66f9d | ||
|
|
58ab66ec51 | ||
|
|
e8b06b2232 | ||
|
|
df8b1b2179 | ||
|
|
4d730e154c | ||
|
|
b9fb8ac702 | ||
|
|
5c38f0979e | ||
|
|
024d48d9c1 | ||
|
|
29ded8f9f9 | ||
|
|
68307c1411 | ||
|
|
2804327074 | ||
|
|
8d3d48e000 | ||
|
|
8864112a0c | ||
|
|
9ed3a061c3 | ||
|
|
6e967446e4 | ||
|
|
0d7303b798 | ||
|
|
d1ce7a9b5e | ||
|
|
5def4f4a1c | ||
|
|
1c6ff310ae | ||
|
|
48605226dd | ||
|
|
f46c1aede0 | ||
|
|
73d128238e | ||
|
|
787fc41fa4 | ||
|
|
5783adcc6f | ||
|
|
503b6dd8be | ||
|
|
9e9ea6e974 | ||
|
|
459744c9ea | ||
|
|
7675d323fa | ||
|
|
270942094f | ||
|
|
be190e990f | ||
|
|
4d7927047c | ||
|
|
ddb4040aa0 | ||
|
|
c1e6888fc7 | ||
|
|
3ae7140690 | ||
|
|
bcf7b63d7e | ||
|
|
c5bf868940 | ||
|
|
42fd964090 | ||
|
|
979d29b5f5 | ||
|
|
1f4a34588b | ||
|
|
a82f275619 | ||
|
|
b3c3a9f174 | ||
|
|
042f82ea32 | ||
|
|
633d08bd7b | ||
|
|
d35ce1add9 | ||
|
|
c3ab36cb9d | ||
|
|
8032b966a1 | ||
|
|
d78b334964 | ||
|
|
161d1d281a | ||
|
|
1145b9751d | ||
|
|
1e876a3c1d | ||
|
|
a8f10c23b2 | ||
|
|
b2b5379348 | ||
|
|
13de36303d | ||
|
|
095d3edd33 | ||
|
|
43819309e1 | ||
|
|
1b8a0dfe5e | ||
|
|
018a382729 | ||
|
|
2e07245384 | ||
|
|
aa87e999dc | ||
|
|
f58751eb2b | ||
|
|
ce11c82d51 | ||
|
|
90ba26cea1 | ||
|
|
7778d708a6 | ||
|
|
f66ddb544c | ||
|
|
e3b2250e26 | ||
|
|
6f521c138d | ||
|
|
04a3118d45 | ||
|
|
c791e64881 | ||
|
|
7330aa593e | ||
|
|
7f17e04a5a | ||
|
|
4722f7e322 | ||
|
|
3ede3aafe4 | ||
|
|
f844791e15 | ||
|
|
cd35a79136 | ||
|
|
f85dc6f97c | ||
|
|
5acc7c4b1e | ||
|
|
c328770184 | ||
|
|
588a234fdc | ||
|
|
c3ef6fb4ee | ||
|
|
85de580455 | ||
|
|
d0906cda97 | ||
|
|
7c386ca6d2 | ||
|
|
7f057d7489 | ||
|
|
c7cea825ae | ||
|
|
280255acae | ||
|
|
ff1b35ec6c | ||
|
|
9a655a1d58 | ||
|
|
28cb1221ba | ||
|
|
d5a870b4dc | ||
|
|
162488a775 | ||
|
|
c5150eae67 | ||
|
|
80b138f0df | ||
|
|
4b49ca4a12 | ||
|
|
10f1c90f4d | ||
|
|
29f7df9d8f | ||
|
|
83c41f3697 | ||
|
|
20f17d6e7b | ||
|
|
bd0c32ca21 | ||
|
|
b7f51a1468 | ||
|
|
f352f8a0e6 | ||
|
|
8dec1a8724 | ||
|
|
4ecc7fdf5f | ||
|
|
6866aaeab3 | ||
|
|
c889254b42 | ||
|
|
228d0c6aea | ||
|
|
64bbf1738e | ||
|
|
a5fd51ebdc | ||
|
|
a7c910e361 | ||
|
|
edb02b63f8 | ||
|
|
782ccb5655 | ||
|
|
bb34589748 | ||
|
|
9e50da321b | ||
|
|
bdc7a61c24 | ||
|
|
33b006cacf | ||
|
|
e5d272f445 | ||
|
|
7c95734907 | ||
|
|
8546ff98fb | ||
|
|
c153e6ae2f | ||
|
|
11e6247d2a | ||
|
|
690446c784 | ||
|
|
cef0a474f8 | ||
|
|
03b2c44a21 | ||
|
|
1bec2cbbd5 | ||
|
|
f571536598 | ||
|
|
e09c434e5d | ||
|
|
e1b71c83ac | ||
|
|
a257b2f88b | ||
|
|
fb18af5564 | ||
|
|
c573bef0aa | ||
|
|
6cfcb3cae4 | ||
|
|
e978299bf0 | ||
|
|
22680a11ae | ||
|
|
75784e10e2 | ||
|
|
6a627e5a33 | ||
|
|
92459a9248 | ||
|
|
7012bf7981 | ||
|
|
07b29f13dc | ||
|
|
f49b9f75b8 | ||
|
|
c0e0a5458f | ||
|
|
81fd00a6b7 | ||
|
|
d42d570066 | ||
|
|
2c0bda6e2e | ||
|
|
3d29da105c | ||
|
|
765d3253f3 | ||
|
|
ba4e58f429 | ||
|
|
7bfb7744b7 | ||
|
|
f475e5550c | ||
|
|
45138fcfba | ||
|
|
b0ed863d55 | ||
|
|
4d1b3bc26f | ||
|
|
6d5c3c1637 | ||
|
|
5a3da3cd7f | ||
|
|
90fd04cbde | ||
|
|
e3cb8cc88d | ||
|
|
8d3acc9235 | ||
|
|
483109b8fc | ||
|
|
59879e5770 | ||
|
|
1bf65e4760 | ||
|
|
38bbb30aaf | ||
|
|
f4da995940 | ||
|
|
02582083d5 | ||
|
|
40fa2a420c | ||
|
|
8ed4fd1dbc | ||
|
|
3b39ca9017 | ||
|
|
e0d291ab8a | ||
|
|
2b00d6922f | ||
|
|
7b4e85aa78 | ||
|
|
e99c7c3ee5 | ||
|
|
38e4d303a2 | ||
|
|
62a1e9a44f | ||
|
|
985535aebc | ||
|
|
d1d5d52b2c | ||
|
|
2522b0615f | ||
|
|
c98652c333 | ||
|
|
524f53de89 | ||
|
|
8c2b755b2e | ||
|
|
a31e43f760 | ||
|
|
c628132b34 | ||
|
|
e04acabfde | ||
|
|
cb960d6cdd | ||
|
|
27e37cf9b3 | ||
|
|
946451b43e | ||
|
|
840d69e1eb | ||
|
|
3ba9f8dd04 | ||
|
|
7c99210e68 | ||
|
|
920ec69241 | ||
|
|
2a933c1903 | ||
|
|
43f7ec48ca | ||
|
|
3177ccabe5 | ||
|
|
7908b6d616 | ||
|
|
ed10a1769b | ||
|
|
5ba57e4661 | ||
|
|
d5abdd915e | ||
|
|
74eb99aed1 | ||
|
|
09d0b632d4 | ||
|
|
d39a5e4417 | ||
|
|
d2fd101eb4 | ||
|
|
8ac5976897 | ||
|
|
7300b908fb | ||
|
|
ca19cf13e9 | ||
|
|
33b359642e | ||
|
|
6f9aed1656 | ||
|
|
4cb1bfee44 | ||
|
|
4a89642f7f | ||
|
|
9e81db50f6 | ||
|
|
8a11f76a0d | ||
|
|
ec90522a53 | ||
|
|
0e203e414f | ||
|
|
0bf8c8e710 | ||
|
|
f6ea6863de | ||
|
|
bb31fd7d1c | ||
|
|
535fad16f8 | ||
|
|
f61b306133 | ||
|
|
583e86b7df | ||
|
|
df89b7de10 | ||
|
|
8a246487c2 | ||
|
|
8765568373 | ||
|
|
9d8b7a7383 | ||
|
|
57a008a1e1 | ||
|
|
13377e6458 | ||
|
|
9de8287d47 | ||
|
|
c350cd1f06 | ||
|
|
f13b8bf0cf | ||
|
|
731688e5cc | ||
|
|
7083246409 | ||
|
|
d92047cc30 | ||
|
|
7a97e64ef0 | ||
|
|
cc3806056f | ||
|
|
916aa782af | ||
|
|
60cd4ac08d | ||
|
|
1b78dc1f33 | ||
|
|
3efd83555f | ||
|
|
812025a39c | ||
|
|
39b289578e | ||
|
|
c9a4dbe383 | ||
|
|
f11c270c6b | ||
|
|
d2dec13392 | ||
|
|
e7a78bc28f | ||
|
|
df02bb013a | ||
|
|
ebc630c6c0 | ||
|
|
ccace1f7df | ||
|
|
e1fb687104 | ||
|
|
654b5a0616 | ||
|
|
50d211d1a4 | ||
|
|
e59dc29a55 | ||
|
|
60a028a4f6 | ||
|
|
927e2e3e7c | ||
|
|
82e067e0ff | ||
|
|
95494a155e | ||
|
|
9534783758 | ||
|
|
f34590d9ed | ||
|
|
c6d96a2b61 | ||
|
|
0498d5ea86 | ||
|
|
1f95bfedf7 | ||
|
|
9526858b1e | ||
|
|
df3996cae3 | ||
|
|
97b6d3e917 | ||
|
|
9ebab961c9 | ||
|
|
6d3490f399 | ||
|
|
51b0169b10 | ||
|
|
b4d3e2928b | ||
|
|
2b892ad6e7 | ||
|
|
6ef2105a8e | ||
|
|
8c4adde083 | ||
|
|
c87782ba9d | ||
|
|
09e0ccf4c2 | ||
|
|
a1d9f65354 | ||
|
|
5e8a80b845 | ||
|
|
558735bc63 | ||
|
|
489e27f085 | ||
|
|
56526ff57f | ||
|
|
09aed46d44 | ||
|
|
223713d4a1 | ||
|
|
83fa17d26c | ||
|
|
958c89470b | ||
|
|
e109cf9fdd | ||
|
|
3ff44b2307 | ||
|
|
ccdd534e81 | ||
|
|
047b324933 | ||
|
|
f0d6228c52 | ||
|
|
920de86cee | ||
|
|
b64d78d58f | ||
|
|
ea81bffdeb | ||
|
|
1e72de6b72 | ||
|
|
92fc243755 | ||
|
|
3471fbf8dc | ||
|
|
b797f773c7 | ||
|
|
dad78f31f3 | ||
|
|
be027a9899 | ||
|
|
87b4bbb94f | ||
|
|
4c2f67a1d0 | ||
|
|
e69682678f | ||
|
|
a2be1aabfa | ||
|
|
ce99474317 | ||
|
|
f4f8ed98d9 | ||
|
|
6eca47b16c | ||
|
|
48f6c1eba4 | ||
|
|
b0cb39cda1 | ||
|
|
c09578d060 | ||
|
|
a75360ccd6 | ||
|
|
5b68dcc8c1 | ||
|
|
3862a1e1d5 | ||
|
|
be107f92d3 | ||
|
|
9245d813c6 | ||
|
|
f7a7957a11 | ||
|
|
49e2d3a7bd | ||
|
|
b46c5ae82a | ||
|
|
7e6c5a2db4 | ||
|
|
9112e78925 | ||
|
|
3b18e65c6a | ||
|
|
6ac6ddbb47 | ||
|
|
9687f3700d | ||
|
|
2263d9c44b | ||
|
|
387b68fe11 | ||
|
|
df2561f6a2 | ||
|
|
96a555fc5a | ||
|
|
0f4359116e | ||
|
|
9ff51ca17f | ||
|
|
045f995203 | ||
|
|
f6cd24499b | ||
|
|
51eb0b2cb7 | ||
|
|
d379a25ae4 | ||
|
|
69f9c17555 | ||
|
|
1a30b2d73f | ||
|
|
57a44846ae | ||
|
|
a9c17dbf93 | ||
|
|
2d3ae485e3 | ||
|
|
b9ebf7cf14 | ||
|
|
12100320d2 | ||
|
|
73fa7dd7af | ||
|
|
88c7d19d54 | ||
|
|
e2d652ec4d | ||
|
|
3f8e8b04fd | ||
|
|
3e71e0ef68 | ||
|
|
7b73c9628d | ||
|
|
d92ef4c215 | ||
|
|
27575cd52d | ||
|
|
ef6f66bb9a | ||
|
|
1410682fb6 | ||
|
|
283a84724f | ||
|
|
e1530cdfcc | ||
|
|
5eb8a2a86a | ||
|
|
d8286d0dc2 | ||
|
|
51288221ce | ||
|
|
06302e30ae | ||
|
|
311352d195 | ||
|
|
0df11253ec | ||
|
|
f18beaa1e4 | ||
|
|
7985f5243a | ||
|
|
ff168a806e | ||
|
|
bb7033174c | ||
|
|
7e4788e383 | ||
|
|
9cb332f0e2 | ||
|
|
0c1510739c | ||
|
|
06134e9521 | ||
|
|
0d19f5d421 | ||
|
|
d41f6a8752 | ||
|
|
768df4ff7a | ||
|
|
e3211ff88b | ||
|
|
49c206fe1e | ||
|
|
780c56e119 | ||
|
|
e484e1c0fc | ||
|
|
bf7573c9ee | ||
|
|
9ab992e7a1 | ||
|
|
0582829e00 | ||
|
|
e851d134cf | ||
|
|
04be5ea725 | ||
|
|
d4122c9f0a | ||
|
|
b0eba129e6 | ||
|
|
0ab6a7e7f5 | ||
|
|
587eb32a83 | ||
|
|
cf74ee49ee | ||
|
|
fc4b25d9fd | ||
|
|
44e027abca | ||
|
|
46467e39c2 | ||
|
|
daa2f1c66e | ||
|
|
64181e17c8 | ||
|
|
66621ab38e | ||
|
|
7444dabb68 | ||
|
|
abc874b04e | ||
|
|
61a345c8e1 | ||
|
|
06a10125fc | ||
|
|
7e65a11df5 | ||
|
|
499d82af8a | ||
|
|
860734aed9 | ||
|
|
0b8f89c79c | ||
|
|
f9b746846f | ||
|
|
e220fa65dd | ||
|
|
cd18bb68a4 | ||
|
|
d38abe90be | ||
|
|
5a2fa3aa95 | ||
|
|
5787989d74 | ||
|
|
6dabb34c7f | ||
|
|
093139fafd | ||
|
|
3db894b78c | ||
|
|
306c8a713c | ||
|
|
149de5e6d6 | ||
|
|
45d9784f9d | ||
|
|
303048a7d5 | ||
|
|
e8a028cf82 | ||
|
|
a7eab788e4 | ||
|
|
1ba0b7fd79 | ||
|
|
7ca54c890e | ||
|
|
8ed27d65ef | ||
|
|
1dadbbb72a | ||
|
|
d811c5a7f0 | ||
|
|
4a99481a11 | ||
|
|
8b9ee7a558 | ||
|
|
300664f8ae | ||
|
|
4531be4406 | ||
|
|
390db46aad | ||
|
|
607c3eb813 | ||
|
|
ee471ca1c8 | ||
|
|
c01c84ea8e | ||
|
|
181a3da513 | ||
|
|
6927a844b1 | ||
|
|
6de3459bc8 | ||
|
|
f145c2b65b | ||
|
|
f9667e4946 | ||
|
|
fdc2018d67 | ||
|
|
10b20fd1c7 | ||
|
|
2eb25686d7 | ||
|
|
253333b8a3 | ||
|
|
5e186f9fbf | ||
|
|
471053a054 | ||
|
|
2a43fa4421 | ||
|
|
9fc3d00c17 | ||
|
|
4022796484 | ||
|
|
afe19d1d81 | ||
|
|
fe5558094c | ||
|
|
ea8b896c6c | ||
|
|
11fafdac8f | ||
|
|
01d58c9b61 | ||
|
|
bd81d520ab | ||
|
|
b64d900f0f | ||
|
|
70a2929a12 | ||
|
|
8b2ae47c31 | ||
|
|
9e6b4d7ad8 | ||
|
|
5bca44d572 | ||
|
|
fa932fefe7 | ||
|
|
21fda7f670 | ||
|
|
7d204d89c2 | ||
|
|
9ad36d17a3 | ||
|
|
2ca6dd1f1d | ||
|
|
da75e49223 | ||
|
|
78980a4ccf | ||
|
|
6799ef838f | ||
|
|
9e4d99305b | ||
|
|
0e4f2bdd0c | ||
|
|
33f29a1532 | ||
|
|
ba48ec5e39 | ||
|
|
3c107ff301 | ||
|
|
6ef834a6b7 | ||
|
|
89bd414be6 | ||
|
|
2f4df30c75 | ||
|
|
62f4df3257 | ||
|
|
fb84ccd82d | ||
|
|
2477fc4952 | ||
|
|
05adf22383 | ||
|
|
31e2e9a300 | ||
|
|
f0f2b2e22b | ||
|
|
9be47f789c | ||
|
|
cab2b2b59e | ||
|
|
59c254579e | ||
|
|
0fd2f71a1e | ||
|
|
2a094181df | ||
|
|
6d84f3409b | ||
|
|
99b9d7a621 | ||
|
|
1acdcff63e | ||
|
|
4daba23cd4 | ||
|
|
6bae55e351 | ||
|
|
0e3fb91a39 | ||
|
|
b6908181ff | ||
|
|
0e1403ec39 | ||
|
|
8cf2805cca | ||
|
|
38b32be926 | ||
|
|
880a41bfcc | ||
|
|
d2301db49c | ||
|
|
0aa7b495d5 | ||
|
|
02a2dcfc86 | ||
|
|
2dc3dc21a8 | ||
|
|
5ba2543828 | ||
|
|
c4eb857405 | ||
|
|
03645f0c27 | ||
|
|
2755f3843c | ||
|
|
7393ce5e4f | ||
|
|
cf8dd7aa09 | ||
|
|
f7b3156f16 | ||
|
|
b1248442c3 | ||
|
|
623176ebc9 | ||
|
|
10085063fb | ||
|
|
51e1ab5560 | ||
|
|
648aa00a28 | ||
|
|
a6c6979b85 | ||
|
|
598ec463bc | ||
|
|
8e6a1ab175 | ||
|
|
27d146d4f8 | ||
|
|
ca45fe2d46 | ||
|
|
30e0156430 | ||
|
|
f8fc3db59c | ||
|
|
4136f27f35 | ||
|
|
0039993359 | ||
|
|
d560a4697a | ||
|
|
d1146dc701 | ||
|
|
3eae75b1b8 | ||
|
|
100d8e909e | ||
|
|
fac1632ed9 | ||
|
|
39e52c4b0a | ||
|
|
01e736e1d5 | ||
|
|
04b57a371e | ||
|
|
73d33e3f20 | ||
|
|
5bba65e978 | ||
|
|
4441609d8f | ||
|
|
fede3cd704 | ||
|
|
e51cf1b09d | ||
|
|
7921198c05 | ||
|
|
0dc9cbc9ab | ||
|
|
4950e6117e | ||
|
|
969b9ed91f | ||
|
|
5242e0f291 | ||
|
|
f991288ceb | ||
|
|
4973956419 | ||
|
|
947c14793a | ||
|
|
71029cea2d | ||
|
|
11f7f7d4a0 | ||
|
|
50da265608 | ||
|
|
2fc8de485c | ||
|
|
8c20da2568 | ||
|
|
4a869048bf | ||
|
|
8c03a31d10 | ||
|
|
0d794a10ff | ||
|
|
a1b4ab34e6 | ||
|
|
2703d6916f | ||
|
|
7439bc7ba6 | ||
|
|
9bd6a2fb8d | ||
|
|
d5cb016cef | ||
|
|
6f992909f0 | ||
|
|
038d25bd04 | ||
|
|
2d29f77b24 | ||
|
|
2adbb48632 | ||
|
|
9af7e8a0ff | ||
|
|
44d73ce932 | ||
|
|
d8feeeee4c | ||
|
|
30380403d0 | ||
|
|
b49aa6ac31 | ||
|
|
586a88c710 | ||
|
|
e8b695626e | ||
|
|
c1daa42c24 | ||
|
|
6e5faff51e | ||
|
|
c8db70fd73 | ||
|
|
140b9aad5c | ||
|
|
e002260b62 | ||
|
|
06fff461dc | ||
|
|
b6aa1c1f22 | ||
|
|
b74db24149 | ||
|
|
65c9ce5a1b | ||
|
|
64547b2b86 | ||
|
|
fd92fbd69e | ||
|
|
d5100e0910 | ||
|
|
ba5aa2c486 | ||
|
|
5ca22a0068 | ||
|
|
4471e403aa | ||
|
|
6793685bba | ||
|
|
73399f784b | ||
|
|
fec888581a | ||
|
|
6edf357b96 | ||
|
|
c129bf1da1 | ||
|
|
71a7b8581d | ||
|
|
4fb663fbd2 | ||
|
|
58ad21b252 | ||
|
|
dd7057682c | ||
|
|
aea251d42a | ||
|
|
2df38b1feb | ||
|
|
3addcacfe9 | ||
|
|
eec734a578 | ||
|
|
3eb986fe05 | ||
|
|
ee6d18e35f | ||
|
|
287fe83f91 | ||
|
|
ef1c902c21 | ||
|
|
b657187a69 | ||
|
|
5f96d6211a | ||
|
|
72cc70ebfc | ||
|
|
3582628691 | ||
|
|
3a018e51bb | ||
|
|
6d85a94767 | ||
|
|
c1a2e2c380 | ||
|
|
3386a59cf1 | ||
|
|
006ec659e6 | ||
|
|
d9144c73a8 | ||
|
|
67f82e62a1 | ||
|
|
f011a0923a | ||
|
|
11ce5b7e57 | ||
|
|
5eded58924 | ||
|
|
355c3b2be7 | ||
|
|
61dfbc0a6e | ||
|
|
8a1201ac42 | ||
|
|
faf2d30439 | ||
|
|
25a0091f69 | ||
|
|
b76dffa594 | ||
|
|
6f18fbce8d | ||
|
|
2ac5474be1 | ||
|
|
3becf82dd3 | ||
|
|
1e67947cfa | ||
|
|
22ebb25e83 | ||
|
|
2afa1672ac | ||
|
|
237b1108b3 | ||
|
|
fff617c988 | ||
|
|
c684ca7a0c | ||
|
|
1116602d4c | ||
|
|
be67b8e75b | ||
|
|
8047dfa2dc | ||
|
|
ebbf5c57b3 | ||
|
|
39efba528f | ||
|
|
69c0b7e712 | ||
|
|
673b3d8dbd | ||
|
|
10eec37cd9 | ||
|
|
8f2bc0708b | ||
|
|
0088c5ddc0 | ||
|
|
907f85cd67 | ||
|
|
8724aa254f | ||
|
|
c4e262a0fc | ||
|
|
eafbf8886d | ||
|
|
b2b8e62476 | ||
|
|
91e64ca74f | ||
|
|
d72575eaaa | ||
|
|
b2c55e62c8 | ||
|
|
467ace7d0c | ||
|
|
aad6830df0 | ||
|
|
ea70aa3d98 | ||
|
|
692eac23ad | ||
|
|
c86d9f2ab1 | ||
|
|
7bfb9999ee | ||
|
|
d2beaea523 | ||
|
|
3599364312 | ||
|
|
a7f05c6bb0 | ||
|
|
eb682d2a0b | ||
|
|
2a1f1c79ca | ||
|
|
6107c65f1e | ||
|
|
a45c9f982a | ||
|
|
84eaef0bbb | ||
|
|
f3c83a06ff | ||
|
|
011f661d5b | ||
|
|
caa2fe394f | ||
|
|
82b9689e25 | ||
|
|
1011e64ad7 | ||
|
|
be10b529ec | ||
|
|
e36cdacf70 | ||
|
|
14e8afe444 | ||
|
|
8aac77aa19 | ||
|
|
f837d179b9 | ||
|
|
2eff9c8277 | ||
|
|
0372e14d79 | ||
|
|
98daf99775 | ||
|
|
7c77c48bd4 | ||
|
|
243490f932 | ||
|
|
a1ded4c166 | ||
|
|
e5fe205c31 | ||
|
|
237f030cd9 | ||
|
|
296f53524c | ||
|
|
a06217a8bd | ||
|
|
5caf609d7b | ||
|
|
0f604923d3 | ||
|
|
3c452b9880 | ||
|
|
14d07b7b20 | ||
|
|
914d115f65 | ||
|
|
af3127711a | ||
|
|
6d5527e4b3 | ||
|
|
d9df023e6f | ||
|
|
651e0d8aad | ||
|
|
fc0fe99edf | ||
|
|
c02ccf6424 | ||
|
|
97c615889d | ||
|
|
a8f07153a6 | ||
|
|
53c4892841 | ||
|
|
8171eb600c | ||
|
|
56f7da0cfd | ||
|
|
350aab05e5 | ||
|
|
55b24009f7 | ||
|
|
3a5fc233aa | ||
|
|
a7ab3429b6 | ||
|
|
da53b1347b | ||
|
|
835a73cc1f | ||
|
|
d857fd00b3 | ||
|
|
8ccd707218 | ||
|
|
c0fcab01ac | ||
|
|
3f4d51c588 | ||
|
|
44be59c15a | ||
|
|
0d47cd2284 | ||
|
|
d81a2b2ce2 | ||
|
|
9c77205ba1 | ||
|
|
c902190e67 | ||
|
|
8dbb3b8bbe | ||
|
|
53a9cc76c7 | ||
|
|
bc8f5a7734 | ||
|
|
3b7ae39a06 | ||
|
|
ca08e316af | ||
|
|
bd2995c14b | ||
|
|
c47578b528 | ||
|
|
041a0e3c27 | ||
|
|
b2d4abf25a | ||
|
|
47002d93a3 | ||
|
|
53e2010b8a | ||
|
|
9c67395334 | ||
|
|
7b65b7f85c | ||
|
|
5a523fdc7f | ||
|
|
9d335aabb2 | ||
|
|
ab4992e10d | ||
|
|
ea5ee6f87c | ||
|
|
b63094431b | ||
|
|
eb1adf629f | ||
|
|
383e203fd2 | ||
|
|
76389d8baf | ||
|
|
389238fe4a | ||
|
|
bdc45b9066 | ||
|
|
e27f4f022e | ||
|
|
d1a5757639 | ||
|
|
2d271f3bd1 | ||
|
|
5f68763cb2 | ||
|
|
98114bf608 | ||
|
|
1b65630e83 | ||
|
|
55e0512a05 | ||
|
|
98f21354c6 | ||
|
|
367228ef82 | ||
|
|
a887ca7efe | ||
|
|
e36c27bcd1 | ||
|
|
e79a1eb24a | ||
|
|
e04aaa7575 | ||
|
|
a469ec8ff6 | ||
|
|
c084c7d7ed | ||
|
|
5ff946a9e6 | ||
|
|
7048024e04 | ||
|
|
1598cd0361 | ||
|
|
4b34c88426 | ||
|
|
79f3a5d753 | ||
|
|
cb525a1aad | ||
|
|
3f16dec1bb | ||
|
|
9c773af04c | ||
|
|
c933b8882c | ||
|
|
964d723aba | ||
|
|
86b6ff61e6 | ||
|
|
cdb924f87b | ||
|
|
7c4d017636 | ||
|
|
57124e2126 | ||
|
|
96cad35870 | ||
|
|
a87e0b4ea8 | ||
|
|
b9dd3fa534 | ||
|
|
5b8323509f | ||
|
|
d6dbefaa91 | ||
|
|
71c0e8d428 | ||
|
|
e1d7d072a3 | ||
|
|
d5b4d2e276 | ||
|
|
74b47eaad6 | ||
|
|
99aa335923 | ||
|
|
a5a3188b7e | ||
|
|
a1084047ce | ||
|
|
74c1f632f6 | ||
|
|
8dd1418774 | ||
|
|
197a4f1ae8 | ||
|
|
a277eb4dcf | ||
|
|
978d6af91a | ||
|
|
5bdca747b7 | ||
|
|
f1ab11e961 | ||
|
|
9a80b8fb10 | ||
|
|
731be07777 | ||
|
|
a6dff4fb74 | ||
|
|
74744b0a4c | ||
|
|
f710d1cb20 | ||
|
|
d2a51f03ce | ||
|
|
a200a23f97 | ||
|
|
82ad585b5b | ||
|
|
adc302f428 | ||
|
|
45042a76cd | ||
|
|
c4980f33f7 | ||
|
|
6d012547b6 | ||
|
|
390d1bb871 | ||
|
|
f1130421f0 | ||
|
|
659e7837c6 | ||
|
|
ad41cbd9d5 | ||
|
|
0a10a5632b | ||
|
|
256ba62e00 | ||
|
|
0cb2ccce7f | ||
|
|
06c4c47d46 | ||
|
|
2e5d08ec4f | ||
|
|
35c10373b5 | ||
|
|
6cc6c70d70 | ||
|
|
6e33d2da2b | ||
|
|
3b0de97e07 | ||
|
|
ea25ef8236 | ||
|
|
5c8d2fa695 | ||
|
|
e8cc78b1af | ||
|
|
8049053f86 | ||
|
|
3b73727e39 | ||
|
|
5676d201d6 | ||
|
|
f45106d47c | ||
|
|
109aa3b2fb | ||
|
|
b0545873e5 | ||
|
|
e567902aa9 | ||
|
|
f3ba268a96 | ||
|
|
699b39dec1 | ||
|
|
7e016c1d90 | ||
|
|
624d9c759b | ||
|
|
b8fe89d15f | ||
|
|
1fdfb0dd08 | ||
|
|
c258015165 | ||
|
|
0a842f353c | ||
|
|
5ea7c7d603 | ||
|
|
732c3d2ed0 | ||
|
|
a3cd171773 | ||
|
|
d321b0ea4f | ||
|
|
992749c44c | ||
|
|
0c4c66948b | ||
|
|
344abaf3d3 | ||
|
|
250edeb3da | ||
|
|
033bd94d4c | ||
|
|
d6021ae71c | ||
|
|
b68d008fee | ||
|
|
20b27df4d0 | ||
|
|
4d3713f631 | ||
|
|
6e6f27dd21 | ||
|
|
dc75b7cfd1 | ||
|
|
b1441d0044 | ||
|
|
7bff7345cc | ||
|
|
5f6fec0eba | ||
|
|
ec790e58df | ||
|
|
3a5d02cb31 | ||
|
|
300aba61a6 | ||
|
|
d4f6efa1df | ||
|
|
b45b948776 | ||
|
|
1ef4be2f86 | ||
|
|
aeb80bf8cb | ||
|
|
6708f9a93f | ||
|
|
ed1fae6c73 | ||
|
|
0f7da5c7dc | ||
|
|
f053f16460 | ||
|
|
8d84178884 | ||
|
|
aeac4bc8e2 | ||
|
|
18c7c3981a | ||
|
|
41dd49391f | ||
|
|
0480a925c1 | ||
|
|
b190c1667b | ||
|
|
5c9203669a | ||
|
|
a0ef51f570 | ||
|
|
b94b91c168 | ||
|
|
575fd5f22b | ||
|
|
33520920c3 | ||
|
|
41e1d336cc | ||
|
|
bdd8ce6692 | ||
|
|
d1e1c025b0 | ||
|
|
538f431d5d | ||
|
|
aac3d5bdd1 | ||
|
|
039ea51ca6 | ||
|
|
a26f23d949 | ||
|
|
063eeefdca | ||
|
|
92fa0313d0 | ||
|
|
f52a6d1b8c | ||
|
|
2847dd2aef | ||
|
|
e2f8b84170 | ||
|
|
2eb0687969 | ||
|
|
3a168cc1ff | ||
|
|
2a991a3541 | ||
|
|
a011320370 | ||
|
|
f1ad26f694 | ||
|
|
f40bb199f5 | ||
|
|
3c27632ffe | ||
|
|
dd50dcd067 | ||
|
|
f18dde6ad1 | ||
|
|
a13753ae1e | ||
|
|
b5299d7d0e | ||
|
|
a97369f097 | ||
|
|
5f6d63936f | ||
|
|
0af61f7c40 | ||
|
|
cec48743fb | ||
|
|
1b8c13e18a | ||
|
|
f3519f7b29 | ||
|
|
ec1e67b1ab | ||
|
|
eff62b7b1b | ||
|
|
1de64e89cd | ||
|
|
b3da5de10f | ||
|
|
b0736fe6f7 | ||
|
|
2f4fca65a1 | ||
|
|
e9c851b04b | ||
|
|
296e712591 | ||
|
|
1e78fc462c | ||
|
|
1f4669a380 | ||
|
|
22238d897b | ||
|
|
1b56acf513 | ||
|
|
513780f4f8 | ||
|
|
4caca8619e | ||
|
|
4fc8538e2f | ||
|
|
49b0ce8180 | ||
|
|
976e88d430 | ||
|
|
97319a1970 | ||
|
|
2d653230ef | ||
|
|
6ea2d01626 | ||
|
|
d3878ecd62 | ||
|
|
b08f37d069 | ||
|
|
6d48a54b3d | ||
|
|
235309adc4 | ||
|
|
751f866f01 | ||
|
|
fe81ee62d7 | ||
|
|
e0cadc5496 | ||
|
|
1950e56478 | ||
|
|
f81351fdef | ||
|
|
42855d219b | ||
|
|
0cc65b4bbe | ||
|
|
3271daf7a3 | ||
|
|
fb392e34b5 | ||
|
|
96e1582298 | ||
|
|
a255a08ea6 | ||
|
|
13bee8e91c | ||
|
|
7c285fe7ee | ||
|
|
3114eacbb8 | ||
|
|
90bd74fc05 | ||
|
|
3f8e185003 | ||
|
|
b1a6d8e2b1 | ||
|
|
001f482aca | ||
|
|
b87cb2c4a5 | ||
|
|
8e85227059 | ||
|
|
26d1fc867e | ||
|
|
0544d6ed04 | ||
|
|
b5ac9172fd | ||
|
|
9e70daad6f | ||
|
|
29bc021dcd | ||
|
|
74e892cbc2 | ||
|
|
cbc89830c4 | ||
|
|
08e110ebc5 | ||
|
|
66b4a363bd | ||
|
|
e0cd9e9dec | ||
|
|
6aab4fb696 | ||
|
|
d585cbf02a | ||
|
|
4c31183781 | ||
|
|
c60e444696 | ||
|
|
ae18cd02c1 | ||
|
|
6cc0036b40 | ||
|
|
329a0a8406 | ||
|
|
f00a49667d | ||
|
|
4d330bac14 | ||
|
|
c9d6a9cb4d | ||
|
|
56dfdbe190 | ||
|
|
f4a522fd67 | ||
|
|
13cadeabcd | ||
|
|
69e4b8a359 | ||
|
|
9bf3ef4167 | ||
|
|
e3a66e4d2f | ||
|
|
7b5866ac0a | ||
|
|
446057d613 | ||
|
|
7a07bc654b | ||
|
|
9a05cdd2b5 | ||
|
|
d7bfef12cf | ||
|
|
9dfb0916c2 | ||
|
|
65f3dab4c6 | ||
|
|
73b8968404 | ||
|
|
32a4ff3e5f | ||
|
|
6beb3184d5 | ||
|
|
1a94c309ea | ||
|
|
4797bacb7c | ||
|
|
2111357568 | ||
|
|
b683921b87 | ||
|
|
4bccc02413 | ||
|
|
4de643b714 | ||
|
|
25e26c16ee | ||
|
|
c35dcd427f | ||
|
|
df5e40f731 | ||
|
|
79472a4a6e | ||
|
|
2daf0f146c | ||
|
|
acf5839dd2 | ||
|
|
e85613aa2d | ||
|
|
cba1312dab | ||
|
|
abfdcd0f70 | ||
|
|
6d8320a6e9 | ||
|
|
9be8d15979 | ||
|
|
847a8cf917 | ||
|
|
5e703bdb55 | ||
|
|
6acc27a92f | ||
|
|
5bb7e0307c | ||
|
|
bf2d3cd074 | ||
|
|
e0669555dd | ||
|
|
be7556aece | ||
|
|
c2d7940ec0 | ||
|
|
036334e913 | ||
|
|
db2cc393af | ||
|
|
21ef7e5c35 | ||
|
|
da8def8e13 | ||
|
|
bb2cba0cd1 | ||
|
|
6afe26575c | ||
|
|
c3a5489e72 | ||
|
|
76904b82e7 | ||
|
|
0759d78f12 | ||
|
|
a413fa4f85 | ||
|
|
d57cba8655 | ||
|
|
e55ae53169 | ||
|
|
3367136d9e | ||
|
|
18fa1a0ad7 | ||
|
|
2327c6b05f | ||
|
|
e975cb6b05 | ||
|
|
0af57fce4c | ||
|
|
7d6775b082 | ||
|
|
910db02652 | ||
|
|
8c790207a0 | ||
|
|
a0ed2c2eb5 | ||
|
|
06b55ab50f | ||
|
|
988c1f0ac7 | ||
|
|
a7f7e79245 | ||
|
|
f4ff26f577 | ||
|
|
60f77ba515 | ||
|
|
1440742a1c | ||
|
|
2be951a582 | ||
|
|
e2519813b1 | ||
|
|
42f7ef631e | ||
|
|
d98305c537 | ||
|
|
3d8eda5b72 | ||
|
|
5677ed1e85 | ||
|
|
798dba14eb | ||
|
|
ea24895e08 | ||
|
|
7ad636f5b7 | ||
|
|
3336d08d59 | ||
|
|
7b6cd4e659 | ||
|
|
231b88cc51 | ||
|
|
e25ab75795 | ||
|
|
193afe19cb | ||
|
|
120bfc97ce | ||
|
|
4e6e3bd13d | ||
|
|
cfef47ddcc | ||
|
|
dfe67afb4a | ||
|
|
b2035a1dca | ||
|
|
48ddb3af2a | ||
|
|
a3602c28bd | ||
|
|
81fd259133 | ||
|
|
5e9e57ecf5 | ||
|
|
c21a3c4733 | ||
|
|
a44687e71f | ||
|
|
4021ae6b9d | ||
|
|
8c09ae9032 | ||
|
|
944f43f1c8 | ||
|
|
95f3dd1346 | ||
|
|
19b5586573 | ||
|
|
a471681e28 | ||
|
|
d60f7fe33f | ||
|
|
2a9ba28def | ||
|
|
bff202a290 | ||
|
|
35bee36549 | ||
|
|
527741d41f | ||
|
|
a1a2c165e9 | ||
|
|
dafc822654 | ||
|
|
9f39c3b10f | ||
|
|
a2d15924fb | ||
|
|
0957bc5af2 | ||
|
|
20324eeebc | ||
|
|
ba459aeef5 | ||
|
|
660abd7309 | ||
|
|
9beb07b4ff | ||
|
|
575c599410 | ||
|
|
036f70b7b4 | ||
|
|
4597ec1037 | ||
|
|
70dde89c34 | ||
|
|
774fa72d32 | ||
|
|
f36ddd9275 | ||
|
|
3697609aaa | ||
|
|
7149155b80 | ||
|
|
def089f9c9 | ||
|
|
46ce80758d | ||
|
|
67597bfc9e | ||
|
|
7b745a1a50 | ||
|
|
74693793be | ||
|
|
42d9e7171c | ||
|
|
bd47e28638 | ||
|
|
adec726fee | ||
|
|
74637f2c15 | ||
|
|
95f630ced0 | ||
|
|
d13c9cdfb4 | ||
|
|
deac82231c | ||
|
|
69f61dcad8 | ||
|
|
f39847aa52 | ||
|
|
afce773aae | ||
|
|
18c61afeb9 | ||
|
|
d0b7a44840 | ||
|
|
e966f024b0 | ||
|
|
223126fe5b | ||
|
|
d499afac78 | ||
|
|
9c2ad7086c | ||
|
|
9d04ffc782 | ||
|
|
d00b095f14 | ||
|
|
9475801ebe | ||
|
|
37da617380 | ||
|
|
7741e9feb0 | ||
|
|
246274b8e9 | ||
|
|
03ecf335f7 | ||
|
|
14100c0985 | ||
|
|
45b7e8c23c | ||
|
|
630bcb5b67 | ||
|
|
e8a11f6181 | ||
|
|
86c5bddce2 | ||
|
|
9116e92718 | ||
|
|
9ee3df02ee | ||
|
|
3a33895f1b | ||
|
|
a4e707bcf0 | ||
|
|
b55761246b | ||
|
|
af966391c7 | ||
|
|
19dfdeb1bb | ||
|
|
4eed2883db | ||
|
|
c32f9f5865 | ||
|
|
64ea60aaa3 | ||
|
|
a04f1ff9e6 | ||
|
|
63ad49890f | ||
|
|
899b4cae10 | ||
|
|
a515fc517b | ||
|
|
227777154a | ||
|
|
26af329fde | ||
|
|
39d03b6b63 | ||
|
|
3555a49518 | ||
|
|
539c073cf0 | ||
|
|
a315336287 | ||
|
|
d05dd41bc1 | ||
|
|
9a264dac01 | ||
|
|
b2855cfd86 | ||
|
|
4ec6d41682 | ||
|
|
a1a43ed266 | ||
|
|
db863bf00f | ||
|
|
f9120eee57 | ||
|
|
49bae7fd5c | ||
|
|
5363a90272 | ||
|
|
b49eb7d55c | ||
|
|
1b4e4cc1e8 | ||
|
|
79755d3ce5 | ||
|
|
1b9ed9f365 | ||
|
|
e7519adc18 | ||
|
|
e24de8a617 | ||
|
|
c070d39287 | ||
|
|
51d488673a | ||
|
|
680f8d9793 | ||
|
|
614a24763b | ||
|
|
0475ed4a7e | ||
|
|
7df85c6031 | ||
|
|
718914b697 | ||
|
|
529e893f70 | ||
|
|
8a187159b2 | ||
|
|
b2994568fe | ||
|
|
f172fc42f7 | ||
|
|
8fe04b035c | ||
|
|
d29ec4d7a4 | ||
|
|
4de1601ef4 | ||
|
|
91b5c50b43 | ||
|
|
ecf6cdd830 | ||
|
|
f16b77de5d | ||
|
|
c8a3d02989 | ||
|
|
6d76764f37 | ||
|
|
b84ec521bf | ||
|
|
82f5f438e0 | ||
|
|
92ad56ddcb | ||
|
|
84e8f25c21 | ||
|
|
dd045a3767 | ||
|
|
a73c423c8a | ||
|
|
3af0d4d0f2 | ||
|
|
c321363d2c | ||
|
|
24ebf161e8 | ||
|
|
a37ee8483f | ||
|
|
7714261566 | ||
|
|
8602061f32 | ||
|
|
73db56af52 | ||
|
|
01ebef0f4f | ||
|
|
62bc1052a2 | ||
|
|
2243dbccb7 | ||
|
|
b1bd96f114 | ||
|
|
fde20f3403 | ||
|
|
7ffd2fe005 | ||
|
|
2934c5114c | ||
|
|
bdf3d2a63f | ||
|
|
c5ce355756 | ||
|
|
7e0ffc17fd | ||
|
|
17348915fa | ||
|
|
1841d0bf98 | ||
|
|
5c69961a57 | ||
|
|
e5636997c5 | ||
|
|
445c8a4671 | ||
|
|
d7c0410ea8 | ||
|
|
4102a687e3 | ||
|
|
5fc8843c4c | ||
|
|
8343b243e7 | ||
|
|
a7efc7bd17 | ||
|
|
d4811f11a0 | ||
|
|
e73657d7aa | ||
|
|
bb7be74756 | ||
|
|
c581ce7b00 | ||
|
|
420d841292 | ||
|
|
58ffe928af | ||
|
|
ab591906c8 | ||
|
|
9214b293e3 | ||
|
|
44f13d32d7 | ||
|
|
18159431ab | ||
|
|
a5fab23e8f | ||
|
|
4b996ad5e3 | ||
|
|
ebd1637e50 | ||
|
|
e97d5634bf | ||
|
|
f981b1d9da | ||
|
|
9bdf0cd8cd | ||
|
|
7686446c60 | ||
|
|
b1867457a6 | ||
|
|
e3beb4429f | ||
|
|
8a0cb4ef20 | ||
|
|
fb4e23506f | ||
|
|
6d04184325 | ||
|
|
8c72aabbdf | ||
|
|
f7cb535693 | ||
|
|
146f51ce76 | ||
|
|
c66e15772f | ||
|
|
e1bdbfe710 | ||
|
|
acc7baac6d | ||
|
|
91794f6498 | ||
|
|
2c447de6cc | ||
|
|
021bedfb89 | ||
|
|
d988c9f098 | ||
|
|
0607832397 | ||
|
|
565dbc599a | ||
|
|
aadf63da1d | ||
|
|
d5781f61a9 | ||
|
|
a7a0baf6b9 | ||
|
|
e9b98dd2e1 | ||
|
|
b9b0bf65a0 | ||
|
|
c6162c2a94 | ||
|
|
aa5e494aba | ||
|
|
ff13c66f55 | ||
|
|
ed248b04a7 | ||
|
|
8158dd2edc | ||
|
|
6632504f45 | ||
|
|
054ef4de56 | ||
|
|
d045462dfb | ||
|
|
d8eb111ac8 | ||
|
|
832031d54b | ||
|
|
42f1d92ae0 | ||
|
|
41bb47de0e | ||
|
|
3562b5bdfa | ||
|
|
5c42990c2f | ||
|
|
65c24b6334 | ||
|
|
4bda41e701 | ||
|
|
9b71008ef2 | ||
|
|
5623ef0271 | ||
|
|
486eecc063 | ||
|
|
b830c9975f | ||
|
|
4a82b317b7 | ||
|
|
f0347e841f | ||
|
|
027111fb5a | ||
|
|
1ce0e558a7 | ||
|
|
74674b110d | ||
|
|
33ee2c058e | ||
|
|
d34dd43562 | ||
|
|
cf61070e26 | ||
|
|
81574a5c8d | ||
|
|
9c6bdae556 | ||
|
|
82e82d9b7a | ||
|
|
0f16640546 | ||
|
|
aa0064db4d | ||
|
|
45a3de14a6 | ||
|
|
f6da2220d3 | ||
|
|
b22b565947 | ||
|
|
7c49db02a2 | ||
|
|
c312e0d264 | ||
|
|
11fcc3a7b0 | ||
|
|
f03a63910d | ||
|
|
024257ef5a | ||
|
|
eb5939289c | ||
|
|
16939f0d56 | ||
|
|
d5e7e3093d | ||
|
|
708b7bff3d | ||
|
|
81bc4992f2 | ||
|
|
f3ce1e2536 | ||
|
|
e7376aca25 | ||
|
|
ed2b8b3e1d | ||
|
|
c14361e70e | ||
|
|
b302742137 | ||
|
|
62035d6485 | ||
|
|
89fee056d3 | ||
|
|
3ed366ee1e | ||
|
|
2aade349fc | ||
|
|
58abae1f83 | ||
|
|
01e6565e8a | ||
|
|
2400ba28b1 | ||
|
|
2266b59446 | ||
|
|
ad7546fb9f | ||
|
|
255c0472fb | ||
|
|
c5adc5243c | ||
|
|
c9961b8b95 | ||
|
|
8fdf137571 | ||
|
|
9c8bbc7888 | ||
|
|
9240f5c1e2 | ||
|
|
2f702b150e | ||
|
|
672c2c8de8 | ||
|
|
be140add75 | ||
|
|
1f959edeb0 | ||
|
|
56f6fe204b | ||
|
|
f52a659076 | ||
|
|
b8596f2a2f | ||
|
|
060ecb010f | ||
|
|
02de34fb10 | ||
|
|
3344c3b89b | ||
|
|
a0bae4dac8 | ||
|
|
9132b31e43 | ||
|
|
19008a3023 | ||
|
|
ba3cc08b62 | ||
|
|
d8bfb7543e | ||
|
|
265b008e49 | ||
|
|
a5ad57472a | ||
|
|
3564fd61b5 | ||
|
|
cfbbcf6d07 | ||
|
|
9c66dce8e0 | ||
|
|
e470893ba0 | ||
|
|
c72caa6672 | ||
|
|
58f35261d0 | ||
|
|
be95aebabd | ||
|
|
490acdefb6 | ||
|
|
84b74825f0 | ||
|
|
9bd9f37d29 | ||
|
|
185f2e4768 | ||
|
|
53e08bd7ea | ||
|
|
70ed22ccf9 | ||
|
|
7ca17b6bdb | ||
|
|
e945d87d76 | ||
|
|
1ac4a26fee | ||
|
|
761163815c | ||
|
|
9f6c8517e0 | ||
|
|
27f36f77c3 | ||
|
|
122bd667dc | ||
|
|
21cd402204 | ||
|
|
0ae0439668 | ||
|
|
6dcc6313a6 | ||
|
|
78dbb59a00 | ||
|
|
7e40071571 | ||
|
|
90dc0e1702 | ||
|
|
2c18517121 | ||
|
|
d6c3588ed3 | ||
|
|
81dba3738e | ||
|
|
ad1cc6cff9 | ||
|
|
68d9d161f4 | ||
|
|
c66f99fcdc | ||
|
|
08b3f5f070 | ||
|
|
66d7d2549f | ||
|
|
d20392d413 | ||
|
|
58cc049a9f | ||
|
|
9b77ac128a | ||
|
|
e1738ea78e | ||
|
|
9bf13fc3d1 | ||
|
|
ab7e6f3f11 | ||
|
|
c5b1565337 | ||
|
|
d2e2d8438b | ||
|
|
23c3831ff9 | ||
|
|
296b008b9f | ||
|
|
31bf3874d6 | ||
|
|
e0c5ac1f02 | ||
|
|
e8f09d24c7 | ||
|
|
70f9fc8c7a | ||
|
|
f1c9812188 | ||
|
|
214242ff62 | ||
|
|
531ccca648 | ||
|
|
3d328b82ee | ||
|
|
d95b95038c | ||
|
|
ceb8c5d1e9 | ||
|
|
d1dd04e327 | ||
|
|
79cf550823 | ||
|
|
79905a1162 | ||
|
|
7d1357162e | ||
|
|
e4b5b92b82 | ||
|
|
ff97a97f08 | ||
|
|
f580f4484f | ||
|
|
7a5cf39d0d | ||
|
|
f81723ceac | ||
|
|
9ccc52cd04 | ||
|
|
5c7e960fa8 | ||
|
|
1a093ef482 | ||
|
|
472529af38 | ||
|
|
039def3b50 | ||
|
|
a78f8fa701 | ||
|
|
b3cc719add | ||
|
|
3fc8683585 | ||
|
|
78b90c3685 | ||
|
|
facafd8819 | ||
|
|
18edd79421 | ||
|
|
5d559141d5 | ||
|
|
f983962fc6 | ||
|
|
b997304bf6 | ||
|
|
9197dd14cc | ||
|
|
3c8d257b3e | ||
|
|
d32700c7b2 | ||
|
|
0de66386d4 | ||
|
|
9ae1161e85 | ||
|
|
03f7e4e577 | ||
|
|
f061d20c9d | ||
|
|
44d62b65d0 | ||
|
|
d53eb6fa11 | ||
|
|
23ec3c104a | ||
|
|
c200229f9e | ||
|
|
766ea96adf | ||
|
|
ffc67806ef | ||
|
|
32a1a3d1c0 | ||
|
|
1c0286e98a | ||
|
|
8f38afbf8e | ||
|
|
c3270af52b | ||
|
|
06eac9bbff | ||
|
|
dbcc34981a | ||
|
|
d4916a8be3 | ||
|
|
64d482ff48 | ||
|
|
25865f81ee | ||
|
|
545639ee44 | ||
|
|
23f37b05a3 | ||
|
|
1cff719015 | ||
|
|
548fa63e49 | ||
|
|
0476c8ebc6 | ||
|
|
1f7479466e | ||
|
|
d942a2ff56 | ||
|
|
90555c5cb2 | ||
|
|
b33c337baa | ||
|
|
77a92f326d | ||
|
|
5d731ca13f | ||
|
|
1c3c6b5382 | ||
|
|
76b0e578c5 | ||
|
|
090033ede5 | ||
|
|
9996d94b3c | ||
|
|
c8dd39fcbc | ||
|
|
539c5e44c5 | ||
|
|
4ee64681ad | ||
|
|
8e821d7aa8 | ||
|
|
3bb57504af | ||
|
|
4497bb0b81 | ||
|
|
0f12ead567 | ||
|
|
ab159f748b | ||
|
|
15b8665787 | ||
|
|
40ec8617ac | ||
|
|
ec9d13bce5 | ||
|
|
01e8ef7293 | ||
|
|
622d80c007 | ||
|
|
486cc9393c | ||
|
|
93324cc7b3 | ||
|
|
18109c63b0 | ||
|
|
b1fff4499f | ||
|
|
f0d6f173c9 | ||
|
|
ddebd30917 | ||
|
|
f50043f6cb | ||
|
|
a9f6cd41fd | ||
|
|
b75f81ec00 | ||
|
|
5055e00cf1 | ||
|
|
f371a1afd9 | ||
|
|
4950fe60bd | ||
|
|
9bb5a038e5 | ||
|
|
5381437664 | ||
|
|
4aa88bc2c0 | ||
|
|
dfcef3382e | ||
|
|
9e5954c598 | ||
|
|
8cfd775885 | ||
|
|
c13fab2a67 | ||
|
|
4001d0bf25 | ||
|
|
8d45d7e312 | ||
|
|
be5eadbecc | ||
|
|
95d43c54bf | ||
|
|
26f103473c | ||
|
|
adc5ffea99 | ||
|
|
5f6abcfa6f | ||
|
|
7c7e23d87a | ||
|
|
52d769d35c | ||
|
|
f04bc31820 | ||
|
|
9a2171e4ea | ||
|
|
8725b14056 | ||
|
|
eb32847d85 | ||
|
|
9dfcdbf478 | ||
|
|
e846481731 | ||
|
|
02a765743e | ||
|
|
e1309e1323 | ||
|
|
fb82299f5a | ||
|
|
116f55ff66 | ||
|
|
a029989aff | ||
|
|
57275a4912 | ||
|
|
a794963e2f | ||
|
|
5d0e3d379c | ||
|
|
b905db7a56 | ||
|
|
357fd85ecf | ||
|
|
c06758c83b | ||
|
|
47f91dd732 | ||
|
|
023d4e2216 | ||
|
|
7a74466998 | ||
|
|
44a9b0170b | ||
|
|
8fd5d3eaf3 | ||
|
|
5e61d52f91 | ||
|
|
acc3b7f259 | ||
|
|
f541e00db2 | ||
|
|
eae003e56f | ||
|
|
e5176f572e | ||
|
|
48e73e147a | ||
|
|
ab60f28227 | ||
|
|
7c3f480767 | ||
|
|
d5fb852718 | ||
|
|
a3d74c4548 | ||
|
|
4dbdb19c26 | ||
|
|
446d03e108 | ||
|
|
97b8c4fa1b | ||
|
|
e6e1976c3a | ||
|
|
617a2ec7cc | ||
|
|
389629258b | ||
|
|
0a6aa75a2d | ||
|
|
04cf46a762 | ||
|
|
51c3d74095 | ||
|
|
fa2fbaf3aa | ||
|
|
7c671b0220 | ||
|
|
dd3e91b678 | ||
|
|
a12aad6b47 | ||
|
|
6a396731eb | ||
|
|
730ca4203c | ||
|
|
7e4883b261 | ||
|
|
7eaf5e509f | ||
|
|
7b1a91dfd3 | ||
|
|
df9f3edea3 | ||
|
|
7fd03ad4b4 | ||
|
|
f85bb60eba | ||
|
|
904723691b | ||
|
|
4dd799ec43 | ||
|
|
d17849461c | ||
|
|
1cae618b03 | ||
|
|
f17873e0f4 | ||
|
|
898695e312 | ||
|
|
2024008667 | ||
|
|
be8a0859a9 | ||
|
|
92357a54ec | ||
|
|
ba91f57ddd | ||
|
|
227c6b2a53 | ||
|
|
90ccba6730 | ||
|
|
f7a36dfeb1 | ||
|
|
9514ed33d2 | ||
|
|
1d33157ab9 | ||
|
|
3e06b9ea7a | ||
|
|
480fd6c797 | ||
|
|
41e60dae80 | ||
|
|
43f3a969ca | ||
|
|
d8cb5aae17 | ||
|
|
b763a12331 | ||
|
|
de2dcda2e0 | ||
|
|
b7f1fe7b0d | ||
|
|
9bd3b5b89c | ||
|
|
cfdb862673 | ||
|
|
6f5096fa61 | ||
|
|
2a22ea3e83 | ||
|
|
4d0461f721 | ||
|
|
393a229de9 | ||
|
|
165c8f898e | ||
|
|
2491fe1afe | ||
|
|
c1cb3efbba | ||
|
|
4c0feba38e | ||
|
|
3c892d106c | ||
|
|
bd4b27753e | ||
|
|
469c30c33b | ||
|
|
c6648db333 | ||
|
|
9fcda1f0a0 | ||
|
|
0d52674a84 | ||
|
|
931f18b575 | ||
|
|
4f1374ec9e | ||
|
|
af412e8874 | ||
|
|
004f0ca3e0 | ||
|
|
16c85d0dc5 | ||
|
|
7fb6781bda | ||
|
|
ec4f849079 | ||
|
|
505ca2750d | ||
|
|
96afd1db46 | ||
|
|
755396d6fe | ||
|
|
cca25f6107 | ||
|
|
e37167b3ef | ||
|
|
d6b8a18b09 | ||
|
|
5bb44a4a5c | ||
|
|
c7993d2b88 | ||
|
|
3709074e55 | ||
|
|
1cfd96cdc2 | ||
|
|
e6572a0f08 | ||
|
|
fe3426b4c7 | ||
|
|
d6817d0f22 | ||
|
|
c93fd0d22b | ||
|
|
9584d8aa7d | ||
|
|
ea6e9099b9 | ||
|
|
72b7edbba9 | ||
|
|
6b71568eb7 | ||
|
|
ec649e707f | ||
|
|
3f4fd64311 | ||
|
|
aa37aece9c | ||
|
|
88c2afd1e3 | ||
|
|
6f58497647 | ||
|
|
88133c361e | ||
|
|
cfa484e1a2 | ||
|
|
ef351ac0dd | ||
|
|
06aa141632 | ||
|
|
3b1f99ded1 | ||
|
|
9280d39678 | ||
|
|
d7f452c0a1 | ||
|
|
09eaba91ad | ||
|
|
b5553c6ad2 | ||
|
|
de4c635e54 | ||
|
|
3ac8ab1791 | ||
|
|
bef6e2831a | ||
|
|
40503ef07a | ||
|
|
412c4c55e2 | ||
|
|
c434e47f2d | ||
|
|
a7d2024e35 | ||
|
|
1d04e01d1e | ||
|
|
9294a14a37 | ||
|
|
7f807fef6c | ||
|
|
35782f891d | ||
|
|
7b9a901489 | ||
|
|
c88bd53b1b | ||
|
|
2d65c1a950 | ||
|
|
4baf34cf25 | ||
|
|
8cdfd12977 | ||
|
|
76256d22d8 | ||
|
|
8c5c87be26 | ||
|
|
4f6fa3d63a | ||
|
|
dee95d0894 | ||
|
|
1007983159 | ||
|
|
51cc0e503b | ||
|
|
87a4c75fd4 | ||
|
|
ef0d740270 | ||
|
|
70a2797064 | ||
|
|
a1e429f7c3 | ||
|
|
526b0b6890 | ||
|
|
467eb2eca0 | ||
|
|
99ed54926b | ||
|
|
13d0b8e6a4 | ||
|
|
59ed846277 | ||
|
|
757ecf7e80 | ||
|
|
22c544bca7 | ||
|
|
f31588786f | ||
|
|
36ea837736 | ||
|
|
4005134263 | ||
|
|
0f05b2c13f | ||
|
|
1392a93445 | ||
|
|
7fffddce8e | ||
|
|
2990c2b1cf | ||
|
|
32c6823cf5 | ||
|
|
b788a5ba7e | ||
|
|
d8c05fc1b2 | ||
|
|
d3643fa151 | ||
|
|
96f73b3894 | ||
|
|
3a182d5dd6 | ||
|
|
6246fa32f0 | ||
|
|
c41837842b | ||
|
|
bb67999808 | ||
|
|
09363064b5 | ||
|
|
edc90ebc61 | ||
|
|
cfb5bd0559 | ||
|
|
db83926121 | ||
|
|
27a1ad6a70 | ||
|
|
4007601f73 | ||
|
|
3b55bf9306 | ||
|
|
bf2fa7b184 | ||
|
|
0d972678e7 | ||
|
|
0df3b76c25 | ||
|
|
c6ac82e3a6 | ||
|
|
0687195bee | ||
|
|
fbc079d82d | ||
|
|
2bac8b6013 | ||
|
|
03e3e6abcd | ||
|
|
a9b4bf1535 | ||
|
|
43f9c25fd2 | ||
|
|
c980bf01be | ||
|
|
a9f32656f5 | ||
|
|
80157f3f37 | ||
|
|
69b535c01f | ||
|
|
e428bba7a3 | ||
|
|
67325d334e | ||
|
|
1336fb740b | ||
|
|
81487169f0 | ||
|
|
3a926348a4 | ||
|
|
2a61261a5a | ||
|
|
928530a112 | ||
|
|
4d85cf586b | ||
|
|
575aacb1e2 | ||
|
|
7cd8c3e839 | ||
|
|
760740905e | ||
|
|
02e580c1d2 | ||
|
|
2903d42921 | ||
|
|
b005b79236 | ||
|
|
c16271fb46 | ||
|
|
0f95eaa8bb | ||
|
|
3f686688a6 | ||
|
|
c163b2a3f1 | ||
|
|
fc5839864b | ||
|
|
c81be7b899 | ||
|
|
36af49ae7f | ||
|
|
bded712e58 | ||
|
|
7cfc6130e5 | ||
|
|
eda647cb47 | ||
|
|
cc91a05686 | ||
|
|
3222bce02d | ||
|
|
acfe5bd33b | ||
|
|
ec4c49a338 | ||
|
|
53f6c3f9f2 | ||
|
|
48d43134d7 | ||
|
|
afb3f62b01 | ||
|
|
da601c23e1 | ||
|
|
e1c1d47991 | ||
|
|
9343967317 | ||
|
|
c48513b2be | ||
|
|
561f7be434 | ||
|
|
dd5548771e | ||
|
|
86069874c9 | ||
|
|
87b44aa311 | ||
|
|
4bb7440094 | ||
|
|
6dae9e47f9 | ||
|
|
4c75605e23 | ||
|
|
395cb588b6 | ||
|
|
d04afc697c | ||
|
|
5cd56fe8d5 | ||
|
|
a253057fc3 | ||
|
|
741ae9956e | ||
|
|
9f3ad40707 | ||
|
|
fd99c54e10 | ||
|
|
679415f3a8 | ||
|
|
c4e9739251 | ||
|
|
e409e59a54 | ||
|
|
025867fd07 | ||
|
|
7966aed1e0 | ||
|
|
35111061e9 | ||
|
|
d1d6ab068e | ||
|
|
c4f06ef7be | ||
|
|
46cb9d98a3 | ||
|
|
c1445155ef | ||
|
|
f9e86e64b7 | ||
|
|
2d1849a7b9 | ||
|
|
7ee3068f9d | ||
|
|
3e1f2d01f7 | ||
|
|
c60cbca371 | ||
|
|
ae483d3446 | ||
|
|
66f9292835 | ||
|
|
512573598a | ||
|
|
2a0b5c21d2 | ||
|
|
7f45734663 | ||
|
|
9e77660931 | ||
|
|
dc71d3559f | ||
|
|
9dee6adfab | ||
|
|
5b85f848dd | ||
|
|
a54671529b | ||
|
|
e3619b890c | ||
|
|
3012a2e1ca | ||
|
|
2ec371fe8b | ||
|
|
d915e0054c | ||
|
|
316523cc1e | ||
|
|
87ba528ae0 | ||
|
|
373176ea54 | ||
|
|
6bed781259 | ||
|
|
deb56f276e | ||
|
|
cfe68d0a86 | ||
|
|
6f5b91c94c | ||
|
|
2336c73d4d | ||
|
|
96fec4b969 | ||
|
|
eff6a404a6 | ||
|
|
71d401cc4e | ||
|
|
1237000efe | ||
|
|
488e63979e | ||
|
|
5a1ef1bbb9 | ||
|
|
e38d3dfc76 | ||
|
|
637cc1b5fc | ||
|
|
1aa75b1c9e | ||
|
|
adcb7e59d2 | ||
|
|
c88506caa6 | ||
|
|
3f7cc3563f | ||
|
|
c6c752cf64 | ||
|
|
50eb8c5add | ||
|
|
48e5f4ff88 | ||
|
|
21413392cf | ||
|
|
3601b43530 | ||
|
|
928d1fddd2 | ||
|
|
5fb8e01a8b | ||
|
|
80ba161c40 | ||
|
|
1a19aed410 | ||
|
|
e97209c6bf | ||
|
|
bbca2c78cb | ||
|
|
d819bb3bb0 | ||
|
|
2265587d38 | ||
|
|
78fededaa5 | ||
|
|
910ae68e0b | ||
|
|
c2eff20008 | ||
|
|
700bd37730 | ||
|
|
90b5f6286c | ||
|
|
db70774685 | ||
|
|
37c94c07cd | ||
|
|
a364bf2b62 | ||
|
|
c994eba763 | ||
|
|
31094d557b | ||
|
|
337c77964b | ||
|
|
8ac4d52b59 | ||
|
|
89832c1a95 | ||
|
|
695f8a1d7e | ||
|
|
53588f632d | ||
|
|
df26c63793 | ||
|
|
8d6793fd70 | ||
|
|
f7cb6630e7 | ||
|
|
5b4154342e | ||
|
|
7a097ccc83 | ||
|
|
2b8b887d55 | ||
|
|
13f75b9667 | ||
|
|
c2b907c965 | ||
|
|
61868f281e | ||
|
|
db7da6622a | ||
|
|
d413850bd7 | ||
|
|
f74ee80abe | ||
|
|
14d077fc3a | ||
|
|
a2c330c496 | ||
|
|
136f30fc92 | ||
|
|
8e40bfc6ea | ||
|
|
1b89662eff | ||
|
|
cf9b9a7fec | ||
|
|
8b81254992 | ||
|
|
0ce67ccda6 | ||
|
|
fc2f628d4c | ||
|
|
33fa43252e | ||
|
|
c8f4dfc8c0 | ||
|
|
cc575fe4d6 | ||
|
|
e3a4952527 | ||
|
|
d9efbd97cb | ||
|
|
c13be0c509 | ||
|
|
91a187bf87 | ||
|
|
a04eebf59f | ||
|
|
d201d217df | ||
|
|
24cd26534f | ||
|
|
9f1dd716e8 | ||
|
|
ecea6cb994 | ||
|
|
e96dd00652 | ||
|
|
945879fa38 | ||
|
|
8f5e5bff1e | ||
|
|
f0e2272e04 | ||
|
|
93221b4535 | ||
|
|
3ffd88a84a | ||
|
|
ade7bd8745 | ||
|
|
4ec83fbad6 | ||
|
|
cd916b728b | ||
|
|
f4f76eb275 | ||
|
|
16f3520089 | ||
|
|
c591c91653 | ||
|
|
67192a2323 | ||
|
|
8ee044ea4a | ||
|
|
da14e024a8 | ||
|
|
df9ce972c7 | ||
|
|
52d32c94d8 | ||
|
|
83c734a6e0 | ||
|
|
8de7f9bff7 | ||
|
|
4f1d6c53cb | ||
|
|
50b4b8b2c6 | ||
|
|
a49d8d5200 | ||
|
|
09c5c9eb83 | ||
|
|
dec68166e4 | ||
|
|
2748750aa2 | ||
|
|
c87ed52ad4 | ||
|
|
3ae701f0eb | ||
|
|
f992749b98 | ||
|
|
f4aad61e67 | ||
|
|
2f69c383a5 | ||
|
|
8f6d8cf979 | ||
|
|
8226f1482c | ||
|
|
f923ce6f87 | ||
|
|
24bdcbe5c7 | ||
|
|
3b3d1b9350 | ||
|
|
7d97800d52 | ||
|
|
2550acfd9d | ||
|
|
f570372b4d | ||
|
|
c6ac29bcc4 | ||
|
|
858ab80172 | ||
|
|
55161b3d92 | ||
|
|
02ad987e24 | ||
|
|
be861797b4 | ||
|
|
e014b4d970 | ||
|
|
c79c72c4fc | ||
|
|
6be7931eb4 | ||
|
|
0b273e1857 | ||
|
|
3603a18710 | ||
|
|
035e8ab00e | ||
|
|
01adcfa688 | ||
|
|
ac2033d98c | ||
|
|
9f604f2bd3 | ||
|
|
3d180c0376 | ||
|
|
f4686a76a1 | ||
|
|
309ddef852 | ||
|
|
7c7f37342f | ||
|
|
909f40da84 | ||
|
|
e4d8d5e78b | ||
|
|
5a44f9f5b5 | ||
|
|
6fecc16c3b | ||
|
|
753f1bfad4 | ||
|
|
df93158aac | ||
|
|
cceacda5eb | ||
|
|
42ee4c917d | ||
|
|
a0e345dba4 | ||
|
|
b2f269d5b7 | ||
|
|
b45bb577a0 | ||
|
|
8294915780 | ||
|
|
06fcf3b225 | ||
|
|
5df12b9059 | ||
|
|
fc12cbfcd3 | ||
|
|
b647977b33 | ||
|
|
d2f3ec8a63 | ||
|
|
77b4fe0afa | ||
|
|
98984c1a9a | ||
|
|
5fa502b5dc | ||
|
|
4fc38888d2 | ||
|
|
8144c6d87d | ||
|
|
0861923c21 | ||
|
|
a121b9f263 | ||
|
|
091ea4a4a5 | ||
|
|
257d75beb1 | ||
|
|
f2b0faf91e | ||
|
|
7fbb6a76ad | ||
|
|
0968b2d55a | ||
|
|
f3b13604b3 | ||
|
|
3ea6ddbb5f | ||
|
|
445c04c938 | ||
|
|
a09c30aac2 | ||
|
|
94aaec5c66 | ||
|
|
7b4960316b | ||
|
|
f6642e0ece | ||
|
|
73314009d0 | ||
|
|
f7e976db55 | ||
|
|
21445b56a5 | ||
|
|
bfb4a4d9e9 | ||
|
|
19f61607b6 | ||
|
|
e41a3b983c | ||
|
|
f2041c9088 | ||
|
|
f30473211b | ||
|
|
32fd42430b | ||
|
|
b775df0b57 | ||
|
|
309c0a13a5 | ||
|
|
7f3d0992aa | ||
|
|
6e91f872af | ||
|
|
1db46919ab | ||
|
|
2a412ac9ee | ||
|
|
18818763d1 | ||
|
|
eaf5591953 | ||
|
|
bd073b8dd6 | ||
|
|
1e12a29806 | ||
|
|
0868329936 | ||
|
|
5f176f24db | ||
|
|
2708544018 | ||
|
|
997b19545b | ||
|
|
ead16b24ec | ||
|
|
9d4ffd135f | ||
|
|
6b9d938c1a | ||
|
|
d8953bf2ba | ||
|
|
84a2dc3a7e | ||
|
|
8c2cb4b431 | ||
|
|
61ee72940c | ||
|
|
1f22507c06 | ||
|
|
c2c97f8f38 | ||
|
|
26021b07ec | ||
|
|
0ef74f37a5 | ||
|
|
9482576bb1 | ||
|
|
97a01b7b17 | ||
|
|
1b57b0380d | ||
|
|
463728a885 | ||
|
|
5cb9999be3 | ||
|
|
927fc36123 | ||
|
|
71b535fc94 | ||
|
|
f695f0b178 | ||
|
|
f143ff89b7 | ||
|
|
d77b4c1344 | ||
|
|
4b1e02057a | ||
|
|
08cf54f386 | ||
|
|
5be42c0af1 | ||
|
|
07f48a7bfe | ||
|
|
858286d97f | ||
|
|
5f529d1359 | ||
|
|
36b148c2d2 | ||
|
|
45a7f6689c | ||
|
|
98b45ef12c | ||
|
|
6e86bbcb06 | ||
|
|
462e75666b | ||
|
|
bf3559171f | ||
|
|
6d61b7906e | ||
|
|
da6ce27416 | ||
|
|
012098ec32 | ||
|
|
ba1adf6c24 | ||
|
|
1dd5cf62a5 | ||
|
|
efc48b0578 | ||
|
|
6b11004a2a | ||
|
|
f8a4df66de | ||
|
|
888e50e1f6 | ||
|
|
1625e87526 | ||
|
|
2bcc047d4f | ||
|
|
c1b3500a05 | ||
|
|
2c89b3a601 | ||
|
|
e82a74553b | ||
|
|
56bf2ce642 | ||
|
|
598c7a22e7 | ||
|
|
06c147d848 | ||
|
|
ba2c0c3145 | ||
|
|
61cdcf4082 | ||
|
|
2fb087891b | ||
|
|
91a8cdc84b | ||
|
|
0f37317664 | ||
|
|
c4f6df47e5 | ||
|
|
21069124db | ||
|
|
740e3c006c | ||
|
|
0588ca5d8b | ||
|
|
da1821197a | ||
|
|
0f31a0fc76 | ||
|
|
26f27a620a | ||
|
|
249758df90 | ||
|
|
d5f8f38ac6 | ||
|
|
105dfa1efa | ||
|
|
0e62a7d1a2 | ||
|
|
c85694fac4 | ||
|
|
b493ef5b71 | ||
|
|
7ddf2e2fea | ||
|
|
f18bb6397b | ||
|
|
db85384f9c | ||
|
|
c9a5dadce8 | ||
|
|
58a6c9b2b8 | ||
|
|
6a2e94cbeb | ||
|
|
55095df644 | ||
|
|
518f6cee63 | ||
|
|
497324ddf6 | ||
|
|
d9a7205be5 | ||
|
|
5d085a6f41 | ||
|
|
4b50977422 | ||
|
|
4686224e5a | ||
|
|
4cbdc84d27 | ||
|
|
6e4f3614cf | ||
|
|
c9eca9451a | ||
|
|
c4a6d9fa5d | ||
|
|
cce6aad6c0 | ||
|
|
e2ed06c53c | ||
|
|
1b5bb2e81d | ||
|
|
8175504584 | ||
|
|
3c2cd854be | ||
|
|
7d897229d9 | ||
|
|
29279b34fa | ||
|
|
38c59c0ad2 | ||
|
|
bb94561c96 | ||
|
|
e31d68d64e | ||
|
|
4fee321004 | ||
|
|
c7a8f0992d | ||
|
|
9cbb0913be | ||
|
|
0fc1479633 | ||
|
|
e921e1b02d | ||
|
|
300d897fd7 | ||
|
|
d19a63ddf6 | ||
|
|
de72a1f9fc | ||
|
|
03caa95bf2 | ||
|
|
e1e20f6d39 | ||
|
|
66f5aa6814 | ||
|
|
f9a50779e2 | ||
|
|
823d970d60 | ||
|
|
84138450a4 | ||
|
|
8c3c5e80b7 | ||
|
|
bb93e29d5c | ||
|
|
4609096271 | ||
|
|
dd6472d4e8 | ||
|
|
fbff1555fc | ||
|
|
94409db7e2 | ||
|
|
a45f8accdb | ||
|
|
8cf6d0a17b | ||
|
|
72d8672ef7 | ||
|
|
53998e26a6 | ||
|
|
2ff481ff10 | ||
|
|
57115e923e | ||
|
|
b486448ab9 | ||
|
|
1b87e025e9 | ||
|
|
6d02a48d8d | ||
|
|
c988bd6ed1 | ||
|
|
1dc4151f8b | ||
|
|
8d6cf14456 | ||
|
|
b4947be0c8 | ||
|
|
01e8a152f7 | ||
|
|
2448c000b3 | ||
|
|
903988b392 | ||
|
|
8267ea0f80 | ||
|
|
8fe503057d | ||
|
|
5d9ab502f3 | ||
|
|
a19c110dd3 | ||
|
|
2db6cd1025 | ||
|
|
be9d564c29 | ||
|
|
3a94ece30c | ||
|
|
86a902b201 | ||
|
|
adda2d2a51 | ||
|
|
a80cef0c13 | ||
|
|
84046d6f7c | ||
|
|
ec62217f52 | ||
|
|
21358cf2f5 | ||
|
|
37e7a387ff | ||
|
|
15599323a1 | ||
|
|
60abeb027b | ||
|
|
b9c92b90db | ||
|
|
e206a3663f | ||
|
|
0173a50bf0 | ||
|
|
dbea8217ac | ||
|
|
82cd98609f | ||
|
|
39d173e5fc | ||
|
|
c8551c8a67 | ||
|
|
3a74f2d2d7 | ||
|
|
24c9dbd129 | ||
|
|
62db629227 | ||
|
|
3c481d6b18 | ||
|
|
b3d268c5a1 | ||
|
|
df8f02db3f | ||
|
|
16652ae52c | ||
|
|
aaba49ca10 | ||
|
|
e64cecac8e | ||
|
|
2a67beaacf | ||
|
|
0626cf4183 | ||
|
|
d7962e3bcf | ||
|
|
6eed2811b2 | ||
|
|
e3dccfd7ff | ||
|
|
fa612c28cf | ||
|
|
e5cd765e00 | ||
|
|
bd90781b34 | ||
|
|
e45d51b060 | ||
|
|
730aa1c89c | ||
|
|
f5ec916214 | ||
|
|
69392411d9 | ||
|
|
02bdc654d5 | ||
|
|
70d71ba1e7 | ||
|
|
1af26222b6 | ||
|
|
857cd6c0d7 | ||
|
|
ae525a7394 | ||
|
|
7a18fe3dca | ||
|
|
c2059d5b8a | ||
|
|
ca774c3249 | ||
|
|
508f332bb2 | ||
|
|
f31546809f | ||
|
|
f3c0023add | ||
|
|
41fd4eab5c | ||
|
|
6feb8f4c51 | ||
|
|
ff3442d92d | ||
|
|
0ada42684b | ||
|
|
7ba874d7f1 | ||
|
|
92dfaf53bb | ||
|
|
411c6c316c | ||
|
|
c64af5e676 | ||
|
|
de4696da10 | ||
|
|
390490e7b1 | ||
|
|
3e50a265be | ||
|
|
185825df11 | ||
|
|
790e41645b | ||
|
|
166fe3fb12 | ||
|
|
6be48dfcc6 | ||
|
|
96f008cf87 | ||
|
|
d5a7eabcd0 | ||
|
|
6cd180746f | ||
|
|
02461ea459 | ||
|
|
8cf1af8a07 | ||
|
|
463b3e8f62 | ||
|
|
a9da6b73a8 | ||
|
|
9fe5ece833 | ||
|
|
5404a0557b | ||
|
|
5a317d312d | ||
|
|
c6c39930cc | ||
|
|
a076aaecc6 | ||
|
|
27da7fd5cb | ||
|
|
a7da236d3d | ||
|
|
a93937abc3 | ||
|
|
26d4ccb816 | ||
|
|
9e8a432146 | ||
|
|
24a04d07d1 | ||
|
|
51bc9a6d9d | ||
|
|
e6626366a2 | ||
|
|
8df3fa4638 | ||
|
|
66f6efa8cb | ||
|
|
189f359609 | ||
|
|
b8ad90c2bf | ||
|
|
b1b0fd119b | ||
|
|
1dc1c8b709 | ||
|
|
408522ddad | ||
|
|
1ffc21ad71 | ||
|
|
dee0833b27 | ||
|
|
b03170b901 | ||
|
|
c5243562d7 | ||
|
|
1a4e8da084 | ||
|
|
138662e248 | ||
|
|
1b426cc232 | ||
|
|
8d0ed1c9ba | ||
|
|
e68d87eb44 | ||
|
|
2cfc96aa90 | ||
|
|
addda5b96f | ||
|
|
64c2657448 | ||
|
|
3690bfecb0 | ||
|
|
28bf53f502 | ||
|
|
c8b63a409e | ||
|
|
a201b89e4a | ||
|
|
506c727e30 | ||
|
|
e2d9c99e5b | ||
|
|
01a9906bf8 | ||
|
|
2aeb93003f | ||
|
|
2513d2d728 | ||
|
|
dd45bba76b | ||
|
|
ebdd25920e | ||
|
|
431329e47c | ||
|
|
7d9b1de3aa | ||
|
|
2c94e3c4ad | ||
|
|
04c2c5bd80 | ||
|
|
96cab21383 | ||
|
|
63d9c7b9b3 | ||
|
|
b09000ad5d | ||
|
|
eb26c081b1 | ||
|
|
44937b59e7 | ||
|
|
535b925d1b | ||
|
|
434af15a04 | ||
|
|
bc537adb1a | ||
|
|
0aa4c6f147 | ||
|
|
ae319b4636 | ||
|
|
c7f5bc0f69 | ||
|
|
81bc812402 | ||
|
|
0848b36dd2 | ||
|
|
39f22a357d | ||
|
|
394c9de02b | ||
|
|
c7052154d5 | ||
|
|
3dedcd1640 | ||
|
|
5a9914a92f | ||
|
|
66164b9307 | ||
|
|
40e2b312b6 | ||
|
|
689426d6bc | ||
|
|
add6dc8ccc | ||
|
|
894693f352 | ||
|
|
4512e213d5 | ||
|
|
8f43ddf1a2 | ||
|
|
681d4897cc | ||
|
|
93ae11105d | ||
|
|
84a1106fa7 | ||
|
|
aac974a5e5 | ||
|
|
6590fc3a94 |
14
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
14
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
@@ -1,18 +1,17 @@
|
||||
name: Bug report
|
||||
description: File a bug report
|
||||
description: File a bug report. If you need help, contact support instead
|
||||
labels: [needs-triage, bug]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Please check if your bug is [already filed](https://github.com/tailscale/tailscale/issues).
|
||||
Have an urgent issue? Let us know by emailing us at <support@tailscale.com>.
|
||||
Need help with your tailnet? [Contact support](https://tailscale.com/contact/support) instead.
|
||||
Otherwise, please check if your bug is [already filed](https://github.com/tailscale/tailscale/issues) before filing a new one.
|
||||
- type: textarea
|
||||
id: what-happened
|
||||
attributes:
|
||||
label: What is the issue?
|
||||
description: What happened? What did you expect to happen?
|
||||
placeholder: oh no
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
@@ -61,6 +60,13 @@ body:
|
||||
placeholder: e.g., 1.14.4
|
||||
validations:
|
||||
required: false
|
||||
- type: textarea
|
||||
id: other-software
|
||||
attributes:
|
||||
label: Other software
|
||||
description: What [other software](https://github.com/tailscale/tailscale/wiki/OtherSoftwareInterop) (networking, security, etc) are you running?
|
||||
validations:
|
||||
required: false
|
||||
- type: input
|
||||
id: bug-report
|
||||
attributes:
|
||||
|
||||
2
.github/ISSUE_TEMPLATE/config.yml
vendored
2
.github/ISSUE_TEMPLATE/config.yml
vendored
@@ -5,4 +5,4 @@ contact_links:
|
||||
about: Contact us for support
|
||||
- name: Troubleshooting
|
||||
url: https://tailscale.com/kb/1023/troubleshooting
|
||||
about: Troubleshoot common issues
|
||||
about: See the troubleshooting guide for help addressing common issues
|
||||
17
.github/licenses.tmpl
vendored
Normal file
17
.github/licenses.tmpl
vendored
Normal file
@@ -0,0 +1,17 @@
|
||||
# Tailscale CLI and daemon dependencies
|
||||
|
||||
The following open source dependencies are used to build the [tailscale][] and
|
||||
[tailscaled][] commands. These are primarily used on Linux and BSD variants as
|
||||
well as an [option for macOS][].
|
||||
|
||||
[tailscale]: https://pkg.go.dev/tailscale.com/cmd/tailscale
|
||||
[tailscaled]: https://pkg.go.dev/tailscale.com/cmd/tailscaled
|
||||
[option for macOS]: https://tailscale.com/kb/1065/macos-variants/
|
||||
|
||||
## Go Packages
|
||||
|
||||
Some packages may only be included on certain architectures or operating systems.
|
||||
|
||||
{{ range . }}
|
||||
- [{{.Name}}](https://pkg.go.dev/{{.Name}}) ([{{.LicenseName}}]({{.LicenseURL}}))
|
||||
{{- end }}
|
||||
26
.github/workflows/cifuzz.yml
vendored
26
.github/workflows/cifuzz.yml
vendored
@@ -1,26 +0,0 @@
|
||||
name: CIFuzz
|
||||
on: [pull_request]
|
||||
jobs:
|
||||
Fuzzing:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Build Fuzzers
|
||||
id: build
|
||||
uses: google/oss-fuzz/infra/cifuzz/actions/build_fuzzers@master
|
||||
with:
|
||||
oss-fuzz-project-name: 'tailscale'
|
||||
dry-run: false
|
||||
language: go
|
||||
- name: Run Fuzzers
|
||||
uses: google/oss-fuzz/infra/cifuzz/actions/run_fuzzers@master
|
||||
with:
|
||||
oss-fuzz-project-name: 'tailscale'
|
||||
fuzz-seconds: 300
|
||||
dry-run: false
|
||||
language: go
|
||||
- name: Upload Crash
|
||||
uses: actions/upload-artifact@v2.2.4
|
||||
if: failure() && steps.build.outcome == 'success'
|
||||
with:
|
||||
name: artifacts
|
||||
path: ./out/artifacts
|
||||
14
.github/workflows/codeql-analysis.yml
vendored
14
.github/workflows/codeql-analysis.yml
vendored
@@ -17,9 +17,15 @@ on:
|
||||
pull_request:
|
||||
# The branches below must be a subset of the branches above
|
||||
branches: [ main ]
|
||||
merge_group:
|
||||
branches: [ main ]
|
||||
schedule:
|
||||
- cron: '31 14 * * 5'
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-$${{ github.head_ref || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
name: Analyze
|
||||
@@ -39,11 +45,11 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v2
|
||||
uses: actions/checkout@v4
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v1
|
||||
uses: github/codeql-action/init@v2
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
# If you wish to specify custom queries, you can do so here or in a config file.
|
||||
@@ -54,7 +60,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@v1
|
||||
uses: github/codeql-action/autobuild@v2
|
||||
|
||||
# ℹ️ Command-line programs to run using the OS shell.
|
||||
# 📚 https://git.io/JvXDl
|
||||
@@ -68,4 +74,4 @@ jobs:
|
||||
# make release
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v1
|
||||
uses: github/codeql-action/analyze@v2
|
||||
|
||||
30
.github/workflows/coverage.yml
vendored
Normal file
30
.github/workflows/coverage.yml
vendored
Normal file
@@ -0,0 +1,30 @@
|
||||
name: Code Coverage
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
branches:
|
||||
- '*'
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
if: "!contains(github.event.head_commit.message, '[ci skip]')"
|
||||
|
||||
steps:
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v4
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: build all
|
||||
run: ./tool/go install ./cmd/...
|
||||
|
||||
- name: Run tests on linux with coverage data
|
||||
run: ./tool/go test -race -covermode atomic -coverprofile=covprofile ./...
|
||||
53
.github/workflows/cross-darwin.yml
vendored
53
.github/workflows/cross-darwin.yml
vendored
@@ -1,53 +0,0 @@
|
||||
name: Darwin-Cross
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
branches:
|
||||
- '*'
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
if: "!contains(github.event.head_commit.message, '[ci skip]')"
|
||||
|
||||
steps:
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v2.1.4
|
||||
with:
|
||||
go-version: 1.17
|
||||
id: go
|
||||
|
||||
- name: Check out code into the Go module directory
|
||||
uses: actions/checkout@v1
|
||||
|
||||
- name: macOS build cmd
|
||||
env:
|
||||
GOOS: darwin
|
||||
GOARCH: amd64
|
||||
run: go build ./cmd/...
|
||||
|
||||
- name: macOS build tests
|
||||
env:
|
||||
GOOS: darwin
|
||||
GOARCH: amd64
|
||||
run: for d in $(go list -f '{{if .TestGoFiles}}{{.Dir}}{{end}}' ./... ); do (echo $d; cd $d && go test -c ); done
|
||||
|
||||
- uses: k0kubun/action-slack@v2.0.0
|
||||
with:
|
||||
payload: |
|
||||
{
|
||||
"attachments": [{
|
||||
"text": "${{ job.status }}: ${{ github.workflow }} <https://github.com/${{ github.repository }}/commit/${{ github.sha }}/checks|${{ env.COMMIT_DATE }} #${{ env.COMMIT_NUMBER_OF_DAY }}> " +
|
||||
"(<https://github.com/${{ github.repository }}/commit/${{ github.sha }}|" + "${{ github.sha }}".substring(0, 10) + ">) " +
|
||||
"of ${{ github.repository }}@" + "${{ github.ref }}".split('/').reverse()[0] + " by ${{ github.event.head_commit.committer.name }}",
|
||||
"color": "danger"
|
||||
}]
|
||||
}
|
||||
env:
|
||||
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
|
||||
if: failure() && github.event_name == 'push'
|
||||
53
.github/workflows/cross-freebsd.yml
vendored
53
.github/workflows/cross-freebsd.yml
vendored
@@ -1,53 +0,0 @@
|
||||
name: FreeBSD-Cross
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
branches:
|
||||
- '*'
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
if: "!contains(github.event.head_commit.message, '[ci skip]')"
|
||||
|
||||
steps:
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v2.1.4
|
||||
with:
|
||||
go-version: 1.17
|
||||
id: go
|
||||
|
||||
- name: Check out code into the Go module directory
|
||||
uses: actions/checkout@v1
|
||||
|
||||
- name: FreeBSD build cmd
|
||||
env:
|
||||
GOOS: freebsd
|
||||
GOARCH: amd64
|
||||
run: go build ./cmd/...
|
||||
|
||||
- name: FreeBSD build tests
|
||||
env:
|
||||
GOOS: freebsd
|
||||
GOARCH: amd64
|
||||
run: for d in $(go list -f '{{if .TestGoFiles}}{{.Dir}}{{end}}' ./... ); do (echo $d; cd $d && go test -c ); done
|
||||
|
||||
- uses: k0kubun/action-slack@v2.0.0
|
||||
with:
|
||||
payload: |
|
||||
{
|
||||
"attachments": [{
|
||||
"text": "${{ job.status }}: ${{ github.workflow }} <https://github.com/${{ github.repository }}/commit/${{ github.sha }}/checks|${{ env.COMMIT_DATE }} #${{ env.COMMIT_NUMBER_OF_DAY }}> " +
|
||||
"(<https://github.com/${{ github.repository }}/commit/${{ github.sha }}|" + "${{ github.sha }}".substring(0, 10) + ">) " +
|
||||
"of ${{ github.repository }}@" + "${{ github.ref }}".split('/').reverse()[0] + " by ${{ github.event.head_commit.committer.name }}",
|
||||
"color": "danger"
|
||||
}]
|
||||
}
|
||||
env:
|
||||
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
|
||||
if: failure() && github.event_name == 'push'
|
||||
53
.github/workflows/cross-openbsd.yml
vendored
53
.github/workflows/cross-openbsd.yml
vendored
@@ -1,53 +0,0 @@
|
||||
name: OpenBSD-Cross
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
branches:
|
||||
- '*'
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
if: "!contains(github.event.head_commit.message, '[ci skip]')"
|
||||
|
||||
steps:
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v2.1.4
|
||||
with:
|
||||
go-version: 1.17
|
||||
id: go
|
||||
|
||||
- name: Check out code into the Go module directory
|
||||
uses: actions/checkout@v1
|
||||
|
||||
- name: OpenBSD build cmd
|
||||
env:
|
||||
GOOS: openbsd
|
||||
GOARCH: amd64
|
||||
run: go build ./cmd/...
|
||||
|
||||
- name: OpenBSD build tests
|
||||
env:
|
||||
GOOS: openbsd
|
||||
GOARCH: amd64
|
||||
run: for d in $(go list -f '{{if .TestGoFiles}}{{.Dir}}{{end}}' ./... ); do (echo $d; cd $d && go test -c ); done
|
||||
|
||||
- uses: k0kubun/action-slack@v2.0.0
|
||||
with:
|
||||
payload: |
|
||||
{
|
||||
"attachments": [{
|
||||
"text": "${{ job.status }}: ${{ github.workflow }} <https://github.com/${{ github.repository }}/commit/${{ github.sha }}/checks|${{ env.COMMIT_DATE }} #${{ env.COMMIT_NUMBER_OF_DAY }}> " +
|
||||
"(<https://github.com/${{ github.repository }}/commit/${{ github.sha }}|" + "${{ github.sha }}".substring(0, 10) + ">) " +
|
||||
"of ${{ github.repository }}@" + "${{ github.ref }}".split('/').reverse()[0] + " by ${{ github.event.head_commit.committer.name }}",
|
||||
"color": "danger"
|
||||
}]
|
||||
}
|
||||
env:
|
||||
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
|
||||
if: failure() && github.event_name == 'push'
|
||||
53
.github/workflows/cross-windows.yml
vendored
53
.github/workflows/cross-windows.yml
vendored
@@ -1,53 +0,0 @@
|
||||
name: Windows-Cross
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
branches:
|
||||
- '*'
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
if: "!contains(github.event.head_commit.message, '[ci skip]')"
|
||||
|
||||
steps:
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v2.1.4
|
||||
with:
|
||||
go-version: 1.17
|
||||
id: go
|
||||
|
||||
- name: Check out code into the Go module directory
|
||||
uses: actions/checkout@v1
|
||||
|
||||
- name: Windows build cmd
|
||||
env:
|
||||
GOOS: windows
|
||||
GOARCH: amd64
|
||||
run: go build ./cmd/...
|
||||
|
||||
- name: Windows build tests
|
||||
env:
|
||||
GOOS: windows
|
||||
GOARCH: amd64
|
||||
run: for d in $(go list -f '{{if .TestGoFiles}}{{.Dir}}{{end}}' ./... ); do (echo $d; cd $d && go test -c ); done
|
||||
|
||||
- uses: k0kubun/action-slack@v2.0.0
|
||||
with:
|
||||
payload: |
|
||||
{
|
||||
"attachments": [{
|
||||
"text": "${{ job.status }}: ${{ github.workflow }} <https://github.com/${{ github.repository }}/commit/${{ github.sha }}/checks|${{ env.COMMIT_DATE }} #${{ env.COMMIT_NUMBER_OF_DAY }}> " +
|
||||
"(<https://github.com/${{ github.repository }}/commit/${{ github.sha }}|" + "${{ github.sha }}".substring(0, 10) + ">) " +
|
||||
"of ${{ github.repository }}@" + "${{ github.ref }}".split('/').reverse()[0] + " by ${{ github.event.head_commit.committer.name }}",
|
||||
"color": "danger"
|
||||
}]
|
||||
}
|
||||
env:
|
||||
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
|
||||
if: failure() && github.event_name == 'push'
|
||||
28
.github/workflows/depaware.yml
vendored
28
.github/workflows/depaware.yml
vendored
@@ -1,28 +0,0 @@
|
||||
name: depaware
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
branches:
|
||||
- '*'
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v2.1.4
|
||||
with:
|
||||
go-version: 1.17
|
||||
|
||||
- name: Check out code
|
||||
uses: actions/checkout@v1
|
||||
|
||||
- name: depaware tailscaled
|
||||
run: go run github.com/tailscale/depaware --check tailscale.com/cmd/tailscaled
|
||||
|
||||
- name: depaware tailscale
|
||||
run: go run github.com/tailscale/depaware --check tailscale.com/cmd/tailscale
|
||||
15
.github/workflows/docker-file-build.yml
vendored
Normal file
15
.github/workflows/docker-file-build.yml
vendored
Normal file
@@ -0,0 +1,15 @@
|
||||
name: "Dockerfile build"
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
branches:
|
||||
- "*"
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: "Build Docker image"
|
||||
run: docker build .
|
||||
27
.github/workflows/flakehub-publish-tagged.yml
vendored
Normal file
27
.github/workflows/flakehub-publish-tagged.yml
vendored
Normal file
@@ -0,0 +1,27 @@
|
||||
name: update-flakehub
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v[0-9]+.*[02468].[0-9]+"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: "The existing tag to publish to FlakeHub"
|
||||
type: "string"
|
||||
required: true
|
||||
jobs:
|
||||
flakehub-publish:
|
||||
runs-on: "ubuntu-latest"
|
||||
permissions:
|
||||
id-token: "write"
|
||||
contents: "read"
|
||||
steps:
|
||||
- uses: "actions/checkout@v4"
|
||||
with:
|
||||
ref: "${{ (inputs.tag != null) && format('refs/tags/{0}', inputs.tag) || '' }}"
|
||||
- uses: "DeterminateSystems/nix-installer-action@main"
|
||||
- uses: "DeterminateSystems/flakehub-push@main"
|
||||
with:
|
||||
visibility: "public"
|
||||
tag: "${{ inputs.tag }}"
|
||||
64
.github/workflows/go-licenses.yml
vendored
Normal file
64
.github/workflows/go-licenses.yml
vendored
Normal file
@@ -0,0 +1,64 @@
|
||||
name: go-licenses
|
||||
|
||||
on:
|
||||
# run action when a change lands in the main branch which updates go.mod or
|
||||
# our license template file. Also allow manual triggering.
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- go.mod
|
||||
- .github/licenses.tmpl
|
||||
- .github/workflows/go-licenses.yml
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-$${{ github.head_ref || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
update-licenses:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v4
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Install go-licenses
|
||||
run: |
|
||||
go install github.com/google/go-licenses@v1.2.2-0.20220825154955-5eedde1c6584
|
||||
|
||||
- name: Run go-licenses
|
||||
env:
|
||||
# include all build tags to include platform-specific dependencies
|
||||
GOFLAGS: "-tags=android,cgo,darwin,freebsd,ios,js,linux,openbsd,wasm,windows"
|
||||
run: |
|
||||
[ -d licenses ] || mkdir licenses
|
||||
go-licenses report tailscale.com/cmd/tailscale tailscale.com/cmd/tailscaled > licenses/tailscale.md --template .github/licenses.tmpl
|
||||
|
||||
- name: Get access token
|
||||
uses: tibdex/github-app-token@b62528385c34dbc9f38e5f4225ac829252d1ea92 # v1.8.0
|
||||
id: generate-token
|
||||
with:
|
||||
app_id: ${{ secrets.LICENSING_APP_ID }}
|
||||
installation_id: ${{ 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
|
||||
with:
|
||||
token: ${{ steps.generate-token.outputs.token }}
|
||||
author: License Updater <noreply+license-updater@tailscale.com>
|
||||
committer: License Updater <noreply+license-updater@tailscale.com>
|
||||
branch: licenses/cli
|
||||
commit-message: "licenses: update tailscale{,d} licenses"
|
||||
title: "licenses: update tailscale{,d} licenses"
|
||||
body: Triggered by ${{ github.repository }}@${{ github.sha }}
|
||||
signoff: true
|
||||
delete-branch: true
|
||||
team-reviewers: opensource-license-reviewers
|
||||
38
.github/workflows/go_generate.yml
vendored
38
.github/workflows/go_generate.yml
vendored
@@ -1,38 +0,0 @@
|
||||
name: go generate
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- "release-branch/*"
|
||||
pull_request:
|
||||
branches:
|
||||
- "*"
|
||||
|
||||
jobs:
|
||||
check:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v2.1.4
|
||||
with:
|
||||
go-version: 1.17
|
||||
|
||||
- name: Check out code
|
||||
uses: actions/checkout@v2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: check 'go generate' is clean
|
||||
run: |
|
||||
if [[ "${{github.ref}}" == release-branch/* ]]
|
||||
then
|
||||
pkgs=$(go list ./... | grep -v dnsfallback)
|
||||
else
|
||||
pkgs=$(go list ./... | grep -v dnsfallback)
|
||||
fi
|
||||
go generate $pkgs
|
||||
echo
|
||||
echo
|
||||
git diff --name-only --exit-code || (echo "The files above need updating. Please run 'go generate'."; exit 1)
|
||||
40
.github/workflows/golangci-lint.yml
vendored
Normal file
40
.github/workflows/golangci-lint.yml
vendored
Normal file
@@ -0,0 +1,40 @@
|
||||
name: golangci-lint
|
||||
on:
|
||||
# For now, only lint pull requests, not the main branches.
|
||||
pull_request:
|
||||
|
||||
# TODO(andrew): enable for main branch after an initial waiting period.
|
||||
#push:
|
||||
# branches:
|
||||
# - main
|
||||
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-$${{ github.head_ref || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
golangci:
|
||||
name: lint
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-go@v4
|
||||
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
|
||||
with:
|
||||
version: v1.54.2
|
||||
|
||||
# Show only new issues if it's a pull request.
|
||||
only-new-issues: true
|
||||
38
.github/workflows/govulncheck.yml
vendored
Normal file
38
.github/workflows/govulncheck.yml
vendored
Normal file
@@ -0,0 +1,38 @@
|
||||
name: govulncheck
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "0 12 * * *" # 8am EST / 10am PST / 12pm UTC
|
||||
workflow_dispatch: # allow manual trigger for testing
|
||||
pull_request:
|
||||
paths:
|
||||
- ".github/workflows/govulncheck.yml"
|
||||
|
||||
jobs:
|
||||
source-scan:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Check out code into the Go module directory
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install govulncheck
|
||||
run: ./tool/go install golang.org/x/vuln/cmd/govulncheck@latest
|
||||
|
||||
- name: Scan source code for known vulnerabilities
|
||||
run: PATH=$PWD/tool/:$PATH "$(./tool/go env GOPATH)/bin/govulncheck" -test ./...
|
||||
|
||||
- uses: ruby/action-slack@v3.2.1
|
||||
with:
|
||||
payload: >
|
||||
{
|
||||
"attachments": [{
|
||||
"title": "${{ job.status }}: ${{ github.workflow }}",
|
||||
"title_link": "https://github.com/${{ github.repository }}/commit/${{ github.sha }}/checks",
|
||||
"text": "${{ github.repository }}@${{ github.sha }}",
|
||||
"color": "danger"
|
||||
}]
|
||||
}
|
||||
env:
|
||||
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
|
||||
if: failure() && github.event_name == 'schedule'
|
||||
102
.github/workflows/installer.yml
vendored
Normal file
102
.github/workflows/installer.yml
vendored
Normal file
@@ -0,0 +1,102 @@
|
||||
name: test installer.sh
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- "main"
|
||||
paths:
|
||||
- scripts/installer.sh
|
||||
pull_request:
|
||||
branches:
|
||||
- "*"
|
||||
paths:
|
||||
- scripts/installer.sh
|
||||
|
||||
jobs:
|
||||
test:
|
||||
strategy:
|
||||
# Don't abort the entire matrix if one element fails.
|
||||
fail-fast: false
|
||||
# Don't start all of these at once, which could saturate Github workers.
|
||||
max-parallel: 4
|
||||
matrix:
|
||||
image:
|
||||
# This is a list of Docker images against which we test our installer.
|
||||
# If you find that some of these no longer exist, please feel free
|
||||
# to remove them from the list.
|
||||
# When adding new images, please only use official ones.
|
||||
- "debian:oldstable-slim"
|
||||
- "debian:stable-slim"
|
||||
- "debian:testing-slim"
|
||||
- "debian:sid-slim"
|
||||
- "ubuntu:18.04"
|
||||
- "ubuntu:20.04"
|
||||
- "ubuntu:22.04"
|
||||
- "ubuntu:22.10"
|
||||
- "ubuntu:23.04"
|
||||
- "elementary/docker:stable"
|
||||
- "elementary/docker:unstable"
|
||||
- "parrotsec/core:lts-amd64"
|
||||
- "parrotsec/core:latest"
|
||||
- "kalilinux/kali-rolling"
|
||||
- "kalilinux/kali-dev"
|
||||
- "oraclelinux:9"
|
||||
- "oraclelinux:8"
|
||||
- "fedora:latest"
|
||||
- "rockylinux:8.7"
|
||||
- "rockylinux:9"
|
||||
- "amazonlinux:latest"
|
||||
- "opensuse/leap:latest"
|
||||
- "opensuse/tumbleweed:latest"
|
||||
- "archlinux:latest"
|
||||
- "alpine:3.14"
|
||||
- "alpine:latest"
|
||||
- "alpine:edge"
|
||||
deps:
|
||||
# Run all images installing curl as a dependency.
|
||||
- curl
|
||||
include:
|
||||
# Check a few images with wget rather than curl.
|
||||
- { image: "debian:oldstable-slim", deps: "wget" }
|
||||
- { image: "debian:sid-slim", deps: "wget" }
|
||||
- { image: "ubuntu:23.04", deps: "wget" }
|
||||
# Ubuntu 16.04 also needs apt-transport-https installed.
|
||||
- { image: "ubuntu:16.04", deps: "curl apt-transport-https" }
|
||||
- { image: "ubuntu:16.04", deps: "wget apt-transport-https" }
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: ${{ matrix.image }}
|
||||
options: --user root
|
||||
steps:
|
||||
- name: install dependencies (yum)
|
||||
# tar and gzip are needed by the actions/checkout below.
|
||||
run: yum install -y --allowerasing tar gzip ${{ matrix.deps }}
|
||||
if: |
|
||||
contains(matrix.image, 'centos')
|
||||
|| contains(matrix.image, 'oraclelinux')
|
||||
|| contains(matrix.image, 'fedora')
|
||||
|| contains(matrix.image, 'amazonlinux')
|
||||
- name: install dependencies (zypper)
|
||||
# tar and gzip are needed by the actions/checkout below.
|
||||
run: zypper --non-interactive install tar gzip ${{ matrix.deps }}
|
||||
if: contains(matrix.image, 'opensuse')
|
||||
- name: install dependencies (apt-get)
|
||||
run: |
|
||||
apt-get update
|
||||
apt-get install -y ${{ matrix.deps }}
|
||||
if: |
|
||||
contains(matrix.image, 'debian')
|
||||
|| contains(matrix.image, 'ubuntu')
|
||||
|| contains(matrix.image, 'elementary')
|
||||
|| contains(matrix.image, 'parrotsec')
|
||||
|| contains(matrix.image, 'kalilinux')
|
||||
- name: checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: run installer
|
||||
run: scripts/installer.sh
|
||||
# Package installation can fail in docker because systemd is not running
|
||||
# as PID 1, so ignore errors at this step. The real check is the
|
||||
# `tailscale --version` command below.
|
||||
continue-on-error: true
|
||||
- name: check tailscale version
|
||||
run: tailscale --version
|
||||
40
.github/workflows/license.yml
vendored
40
.github/workflows/license.yml
vendored
@@ -1,40 +0,0 @@
|
||||
name: license
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
branches:
|
||||
- '*'
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v2.1.4
|
||||
with:
|
||||
go-version: 1.17
|
||||
|
||||
- name: Check out code
|
||||
uses: actions/checkout@v1
|
||||
|
||||
- name: Run license checker
|
||||
run: ./scripts/check_license_headers.sh .
|
||||
|
||||
- uses: k0kubun/action-slack@v2.0.0
|
||||
with:
|
||||
payload: |
|
||||
{
|
||||
"attachments": [{
|
||||
"text": "${{ job.status }}: ${{ github.workflow }} <https://github.com/${{ github.repository }}/commit/${{ github.sha }}/checks|${{ env.COMMIT_DATE }} #${{ env.COMMIT_NUMBER_OF_DAY }}> " +
|
||||
"(<https://github.com/${{ github.repository }}/commit/${{ github.sha }}|" + "${{ github.sha }}".substring(0, 10) + ">) " +
|
||||
"of ${{ github.repository }}@" + "${{ github.ref }}".split('/').reverse()[0] + " by ${{ github.event.head_commit.committer.name }}",
|
||||
"color": "danger"
|
||||
}]
|
||||
}
|
||||
env:
|
||||
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
|
||||
if: failure() && github.event_name == 'push'
|
||||
63
.github/workflows/linux-race.yml
vendored
63
.github/workflows/linux-race.yml
vendored
@@ -1,63 +0,0 @@
|
||||
name: Linux race
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
branches:
|
||||
- '*'
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
if: "!contains(github.event.head_commit.message, '[ci skip]')"
|
||||
|
||||
steps:
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v2.1.4
|
||||
with:
|
||||
go-version: 1.17
|
||||
id: go
|
||||
|
||||
- name: Check out code into the Go module directory
|
||||
uses: actions/checkout@v1
|
||||
|
||||
- name: Basic build
|
||||
run: go build ./cmd/...
|
||||
|
||||
- name: Run tests and benchmarks with -race flag on linux
|
||||
run: go test -race -bench=. -benchtime=1x ./...
|
||||
|
||||
- name: Check that no tracked files in the repo have been modified
|
||||
run: git diff --no-ext-diff --name-only --exit-code || (echo "Build/test modified the files above."; exit 1)
|
||||
|
||||
- name: Check that no files have been added to the repo
|
||||
run: |
|
||||
# Note: The "error: pathspec..." you see below is normal!
|
||||
# In the success case in which there are no new untracked files,
|
||||
# git ls-files complains about the pathspec not matching anything.
|
||||
# That's OK. It's not worth the effort to suppress. Please ignore it.
|
||||
if git ls-files --others --exclude-standard --directory --no-empty-directory --error-unmatch -- ':/*'
|
||||
then
|
||||
echo "Build/test created untracked files in the repo (file names above)."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- uses: k0kubun/action-slack@v2.0.0
|
||||
with:
|
||||
payload: |
|
||||
{
|
||||
"attachments": [{
|
||||
"text": "${{ job.status }}: ${{ github.workflow }} <https://github.com/${{ github.repository }}/commit/${{ github.sha }}/checks|${{ env.COMMIT_DATE }} #${{ env.COMMIT_NUMBER_OF_DAY }}> " +
|
||||
"(<https://github.com/${{ github.repository }}/commit/${{ github.sha }}|" + "${{ github.sha }}".substring(0, 10) + ">) " +
|
||||
"of ${{ github.repository }}@" + "${{ github.ref }}".split('/').reverse()[0] + " by ${{ github.event.head_commit.committer.name }}",
|
||||
"color": "danger"
|
||||
}]
|
||||
}
|
||||
env:
|
||||
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
|
||||
if: failure() && github.event_name == 'push'
|
||||
|
||||
72
.github/workflows/linux.yml
vendored
72
.github/workflows/linux.yml
vendored
@@ -1,72 +0,0 @@
|
||||
name: Linux
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
branches:
|
||||
- '*'
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
if: "!contains(github.event.head_commit.message, '[ci skip]')"
|
||||
|
||||
steps:
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v2.1.4
|
||||
with:
|
||||
go-version: 1.17
|
||||
id: go
|
||||
|
||||
- name: Check out code into the Go module directory
|
||||
uses: actions/checkout@v1
|
||||
|
||||
- name: Basic build
|
||||
run: go build ./cmd/...
|
||||
|
||||
- name: Get QEMU
|
||||
run: |
|
||||
# The qemu in Ubuntu 20.04 (Focal) is too old; we need 5.x something
|
||||
# to run Go binaries. 5.2.0 (Debian bullseye) empirically works, and
|
||||
# use this PPA which brings in a modern qemu.
|
||||
sudo add-apt-repository -y ppa:jacob/virtualisation
|
||||
sudo apt-get -y update
|
||||
sudo apt-get -y install qemu-user
|
||||
|
||||
- name: Run tests on linux
|
||||
run: go test -bench=. -benchtime=1x ./...
|
||||
|
||||
- name: Check that no tracked files in the repo have been modified
|
||||
run: git diff --no-ext-diff --name-only --exit-code || (echo "Build/test modified the files above."; exit 1)
|
||||
|
||||
- name: Check that no files have been added to the repo
|
||||
run: |
|
||||
# Note: The "error: pathspec..." you see below is normal!
|
||||
# In the success case in which there are no new untracked files,
|
||||
# git ls-files complains about the pathspec not matching anything.
|
||||
# That's OK. It's not worth the effort to suppress. Please ignore it.
|
||||
if git ls-files --others --exclude-standard --directory --no-empty-directory --error-unmatch -- ':/*'
|
||||
then
|
||||
echo "Build/test created untracked files in the repo (file names above)."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- uses: k0kubun/action-slack@v2.0.0
|
||||
with:
|
||||
payload: |
|
||||
{
|
||||
"attachments": [{
|
||||
"text": "${{ job.status }}: ${{ github.workflow }} <https://github.com/${{ github.repository }}/commit/${{ github.sha }}/checks|${{ env.COMMIT_DATE }} #${{ env.COMMIT_NUMBER_OF_DAY }}> " +
|
||||
"(<https://github.com/${{ github.repository }}/commit/${{ github.sha }}|" + "${{ github.sha }}".substring(0, 10) + ">) " +
|
||||
"of ${{ github.repository }}@" + "${{ github.ref }}".split('/').reverse()[0] + " by ${{ github.event.head_commit.committer.name }}",
|
||||
"color": "danger"
|
||||
}]
|
||||
}
|
||||
env:
|
||||
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
|
||||
if: failure() && github.event_name == 'push'
|
||||
|
||||
63
.github/workflows/linux32.yml
vendored
63
.github/workflows/linux32.yml
vendored
@@ -1,63 +0,0 @@
|
||||
name: Linux 32-bit
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
branches:
|
||||
- '*'
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
if: "!contains(github.event.head_commit.message, '[ci skip]')"
|
||||
|
||||
steps:
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v2.1.4
|
||||
with:
|
||||
go-version: 1.17
|
||||
id: go
|
||||
|
||||
- name: Check out code into the Go module directory
|
||||
uses: actions/checkout@v1
|
||||
|
||||
- name: Basic build
|
||||
run: GOARCH=386 go build ./cmd/...
|
||||
|
||||
- name: Run tests on linux
|
||||
run: GOARCH=386 go test -bench=. -benchtime=1x ./...
|
||||
|
||||
- name: Check that no tracked files in the repo have been modified
|
||||
run: git diff --no-ext-diff --name-only --exit-code || (echo "Build/test modified the files above."; exit 1)
|
||||
|
||||
- name: Check that no files have been added to the repo
|
||||
run: |
|
||||
# Note: The "error: pathspec..." you see below is normal!
|
||||
# In the success case in which there are no new untracked files,
|
||||
# git ls-files complains about the pathspec not matching anything.
|
||||
# That's OK. It's not worth the effort to suppress. Please ignore it.
|
||||
if git ls-files --others --exclude-standard --directory --no-empty-directory --error-unmatch -- ':/*'
|
||||
then
|
||||
echo "Build/test created untracked files in the repo (file names above)."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- uses: k0kubun/action-slack@v2.0.0
|
||||
with:
|
||||
payload: |
|
||||
{
|
||||
"attachments": [{
|
||||
"text": "${{ job.status }}: ${{ github.workflow }} <https://github.com/${{ github.repository }}/commit/${{ github.sha }}/checks|${{ env.COMMIT_DATE }} #${{ env.COMMIT_NUMBER_OF_DAY }}> " +
|
||||
"(<https://github.com/${{ github.repository }}/commit/${{ github.sha }}|" + "${{ github.sha }}".substring(0, 10) + ">) " +
|
||||
"of ${{ github.repository }}@" + "${{ github.ref }}".split('/').reverse()[0] + " by ${{ github.event.head_commit.committer.name }}",
|
||||
"color": "danger"
|
||||
}]
|
||||
}
|
||||
env:
|
||||
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
|
||||
if: failure() && github.event_name == 'push'
|
||||
|
||||
70
.github/workflows/staticcheck.yml
vendored
70
.github/workflows/staticcheck.yml
vendored
@@ -1,70 +0,0 @@
|
||||
name: staticcheck
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
branches:
|
||||
- '*'
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v2.1.4
|
||||
with:
|
||||
go-version: 1.17
|
||||
|
||||
- name: Check out code
|
||||
uses: actions/checkout@v1
|
||||
|
||||
- name: Run go vet
|
||||
run: go vet ./...
|
||||
|
||||
- name: Install staticcheck
|
||||
run: "GOBIN=~/.local/bin go install honnef.co/go/tools/cmd/staticcheck"
|
||||
|
||||
- name: Print staticcheck version
|
||||
run: "staticcheck -version"
|
||||
|
||||
- name: Run staticcheck (linux/amd64)
|
||||
env:
|
||||
GOOS: linux
|
||||
GOARCH: amd64
|
||||
run: "staticcheck -- $(go list ./... | grep -v tempfork)"
|
||||
|
||||
- name: Run staticcheck (darwin/amd64)
|
||||
env:
|
||||
GOOS: darwin
|
||||
GOARCH: amd64
|
||||
run: "staticcheck -- $(go list ./... | grep -v tempfork)"
|
||||
|
||||
- name: Run staticcheck (windows/amd64)
|
||||
env:
|
||||
GOOS: windows
|
||||
GOARCH: amd64
|
||||
run: "staticcheck -- $(go list ./... | grep -v tempfork)"
|
||||
|
||||
- name: Run staticcheck (windows/386)
|
||||
env:
|
||||
GOOS: windows
|
||||
GOARCH: "386"
|
||||
run: "staticcheck -- $(go list ./... | grep -v tempfork)"
|
||||
|
||||
- uses: k0kubun/action-slack@v2.0.0
|
||||
with:
|
||||
payload: |
|
||||
{
|
||||
"attachments": [{
|
||||
"text": "${{ job.status }}: ${{ github.workflow }} <https://github.com/${{ github.repository }}/commit/${{ github.sha }}/checks|${{ env.COMMIT_DATE }} #${{ env.COMMIT_NUMBER_OF_DAY }}> " +
|
||||
"(<https://github.com/${{ github.repository }}/commit/${{ github.sha }}|" + "${{ github.sha }}".substring(0, 10) + ">) " +
|
||||
"of ${{ github.repository }}@" + "${{ github.ref }}".split('/').reverse()[0] + " by ${{ github.event.head_commit.committer.name }}",
|
||||
"color": "danger"
|
||||
}]
|
||||
}
|
||||
env:
|
||||
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
|
||||
if: failure() && github.event_name == 'push'
|
||||
545
.github/workflows/test.yml
vendored
Normal file
545
.github/workflows/test.yml
vendored
Normal file
@@ -0,0 +1,545 @@
|
||||
# This is our main "CI tests" workflow. It runs everything that should run on
|
||||
# both PRs and merged commits, and for the latter reports failures to slack.
|
||||
name: CI
|
||||
|
||||
env:
|
||||
# Our fuzz job, powered by OSS-Fuzz, fails periodically because we upgrade to
|
||||
# new Go versions very eagerly. OSS-Fuzz is a little more conservative, and
|
||||
# ends up being unable to compile our code.
|
||||
#
|
||||
# When this happens, we want to disable the fuzz target until OSS-Fuzz catches
|
||||
# up. However, we also don't want to forget to turn it back on when OSS-Fuzz
|
||||
# can once again build our code.
|
||||
#
|
||||
# This variable toggles the fuzz job between two modes:
|
||||
# - false: we expect fuzzing to be happy, and should report failure if it's not.
|
||||
# - true: we expect fuzzing is broken, and should report failure if it start working.
|
||||
TS_FUZZ_CURRENTLY_BROKEN: false
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- "main"
|
||||
- "release-branch/*"
|
||||
pull_request:
|
||||
branches:
|
||||
- "*"
|
||||
merge_group:
|
||||
branches:
|
||||
- "main"
|
||||
|
||||
concurrency:
|
||||
# For PRs, later CI runs preempt previous ones. e.g. a force push on a PR
|
||||
# cancels running CI jobs and starts all new ones.
|
||||
#
|
||||
# For non-PR pushes, concurrency.group needs to be unique for every distinct
|
||||
# CI run we want to have happen. Use run_id, which in practice means all
|
||||
# non-PR CI runs will be allowed to run without preempting each other.
|
||||
group: ${{ github.workflow }}-$${{ github.pull_request.number || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
race-root-integration:
|
||||
runs-on: ubuntu-22.04
|
||||
strategy:
|
||||
fail-fast: false # don't abort the entire matrix if one element fails
|
||||
matrix:
|
||||
include:
|
||||
- shard: '1/4'
|
||||
- shard: '2/4'
|
||||
- shard: '3/4'
|
||||
- shard: '4/4'
|
||||
steps:
|
||||
- name: checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: build test wrapper
|
||||
run: ./tool/go build -o /tmp/testwrapper ./cmd/testwrapper
|
||||
- name: integration tests as root
|
||||
run: PATH=$PWD/tool:$PATH /tmp/testwrapper -exec "sudo -E" -race ./tstest/integration/
|
||||
env:
|
||||
TS_TEST_SHARD: ${{ matrix.shard }}
|
||||
|
||||
test:
|
||||
strategy:
|
||||
fail-fast: false # don't abort the entire matrix if one element fails
|
||||
matrix:
|
||||
include:
|
||||
- goarch: amd64
|
||||
- goarch: amd64
|
||||
buildflags: "-race"
|
||||
shard: '1/3'
|
||||
- goarch: amd64
|
||||
buildflags: "-race"
|
||||
shard: '2/3'
|
||||
- goarch: amd64
|
||||
buildflags: "-race"
|
||||
shard: '3/3'
|
||||
- goarch: "386" # thanks yaml
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: Restore Cache
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
# Note: unlike the other setups, this is only grabbing the mod download
|
||||
# cache, rather than the whole mod directory, as the download cache
|
||||
# contains zips that can be unpacked in parallel faster than they can be
|
||||
# fetched and extracted by tar
|
||||
path: |
|
||||
~/.cache/go-build
|
||||
~/go/pkg/mod/cache
|
||||
~\AppData\Local\go-build
|
||||
# The -2- here should be incremented when the scheme of data to be
|
||||
# cached changes (e.g. path above changes).
|
||||
key: ${{ github.job }}-${{ runner.os }}-${{ matrix.goarch }}-${{ matrix.buildflags }}-go-2-${{ hashFiles('**/go.sum') }}-${{ github.run_id }}
|
||||
restore-keys: |
|
||||
${{ github.job }}-${{ runner.os }}-${{ matrix.goarch }}-${{ matrix.buildflags }}-go-2-${{ hashFiles('**/go.sum') }}
|
||||
${{ github.job }}-${{ runner.os }}-${{ matrix.goarch }}-${{ matrix.buildflags }}-go-2-
|
||||
- name: build all
|
||||
if: matrix.buildflags == '' # skip on race builder
|
||||
run: ./tool/go build ${{matrix.buildflags}} ./...
|
||||
env:
|
||||
GOARCH: ${{ matrix.goarch }}
|
||||
- name: build variant CLIs
|
||||
if: matrix.buildflags == '' # skip on race builder
|
||||
run: |
|
||||
export TS_USE_TOOLCHAIN=1
|
||||
./build_dist.sh --extra-small ./cmd/tailscaled
|
||||
./build_dist.sh --box ./cmd/tailscaled
|
||||
./build_dist.sh --extra-small --box ./cmd/tailscaled
|
||||
rm -f tailscaled
|
||||
env:
|
||||
GOARCH: ${{ matrix.goarch }}
|
||||
- name: get qemu # for tstest/archtest
|
||||
if: matrix.goarch == 'amd64' && matrix.buildflags == ''
|
||||
run: |
|
||||
sudo apt-get -y update
|
||||
sudo apt-get -y install qemu-user
|
||||
- name: build test wrapper
|
||||
run: ./tool/go build -o /tmp/testwrapper ./cmd/testwrapper
|
||||
- name: test all
|
||||
run: PATH=$PWD/tool:$PATH /tmp/testwrapper ./... ${{matrix.buildflags}}
|
||||
env:
|
||||
GOARCH: ${{ matrix.goarch }}
|
||||
TS_TEST_SHARD: ${{ matrix.shard }}
|
||||
- name: bench all
|
||||
run: ./tool/go test ${{matrix.buildflags}} -bench=. -benchtime=1x -run=^$ $(for x in $(git grep -l "^func Benchmark" | xargs dirname | sort | uniq); do echo "./$x"; done)
|
||||
env:
|
||||
GOARCH: ${{ matrix.goarch }}
|
||||
- name: check that no tracked files changed
|
||||
run: git diff --no-ext-diff --name-only --exit-code || (echo "Build/test modified the files above."; exit 1)
|
||||
- name: check that no new files were added
|
||||
run: |
|
||||
# Note: The "error: pathspec..." you see below is normal!
|
||||
# In the success case in which there are no new untracked files,
|
||||
# git ls-files complains about the pathspec not matching anything.
|
||||
# That's OK. It's not worth the effort to suppress. Please ignore it.
|
||||
if git ls-files --others --exclude-standard --directory --no-empty-directory --error-unmatch -- ':/*'
|
||||
then
|
||||
echo "Build/test created untracked files in the repo (file names above)."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
windows:
|
||||
runs-on: windows-2022
|
||||
steps:
|
||||
- name: checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install Go
|
||||
uses: actions/setup-go@v4
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: false
|
||||
|
||||
- name: Restore Cache
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
# Note: unlike the other setups, this is only grabbing the mod download
|
||||
# cache, rather than the whole mod directory, as the download cache
|
||||
# contains zips that can be unpacked in parallel faster than they can be
|
||||
# fetched and extracted by tar
|
||||
path: |
|
||||
~/.cache/go-build
|
||||
~/go/pkg/mod/cache
|
||||
~\AppData\Local\go-build
|
||||
# The -2- here should be incremented when the scheme of data to be
|
||||
# cached changes (e.g. path above changes).
|
||||
key: ${{ github.job }}-${{ runner.os }}-go-2-${{ hashFiles('**/go.sum') }}-${{ github.run_id }}
|
||||
restore-keys: |
|
||||
${{ github.job }}-${{ runner.os }}-go-2-${{ hashFiles('**/go.sum') }}
|
||||
${{ github.job }}-${{ runner.os }}-go-2-
|
||||
- name: test
|
||||
run: go run ./cmd/testwrapper ./...
|
||||
- name: bench all
|
||||
# Don't use -bench=. -benchtime=1x.
|
||||
# Somewhere in the layers (powershell?)
|
||||
# the equals signs cause great confusion.
|
||||
run: go test ./... -bench . -benchtime 1x -run "^$"
|
||||
|
||||
vm:
|
||||
runs-on: ["self-hosted", "linux", "vm"]
|
||||
# VM tests run with some privileges, don't let them run on 3p PRs.
|
||||
if: github.repository == 'tailscale/tailscale'
|
||||
steps:
|
||||
- name: checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: Run VM tests
|
||||
run: ./tool/go test ./tstest/integration/vms -v -no-s3 -run-vm-tests -run=TestRunUbuntu2004
|
||||
env:
|
||||
HOME: "/tmp"
|
||||
TMPDIR: "/tmp"
|
||||
XDB_CACHE_HOME: "/var/lib/ghrunner/cache"
|
||||
|
||||
race-build:
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: build all
|
||||
run: ./tool/go install -race ./cmd/...
|
||||
- name: build tests
|
||||
run: ./tool/go test -race -exec=true ./...
|
||||
|
||||
cross: # cross-compile checks, build only.
|
||||
strategy:
|
||||
fail-fast: false # don't abort the entire matrix if one element fails
|
||||
matrix:
|
||||
include:
|
||||
# Note: linux/amd64 is not in this matrix, because that goos/goarch is
|
||||
# tested more exhaustively in the 'test' job above.
|
||||
- goos: linux
|
||||
goarch: arm64
|
||||
- goos: linux
|
||||
goarch: "386" # thanks yaml
|
||||
- goos: linux
|
||||
goarch: loong64
|
||||
- goos: linux
|
||||
goarch: arm
|
||||
goarm: "5"
|
||||
- goos: linux
|
||||
goarch: arm
|
||||
goarm: "7"
|
||||
# macOS
|
||||
- goos: darwin
|
||||
goarch: amd64
|
||||
- goos: darwin
|
||||
goarch: arm64
|
||||
# Windows
|
||||
- goos: windows
|
||||
goarch: amd64
|
||||
- goos: windows
|
||||
goarch: arm64
|
||||
# BSDs
|
||||
- goos: freebsd
|
||||
goarch: amd64
|
||||
- goos: openbsd
|
||||
goarch: amd64
|
||||
# Plan9
|
||||
- goos: plan9
|
||||
goarch: amd64
|
||||
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: Restore Cache
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
# Note: unlike the other setups, this is only grabbing the mod download
|
||||
# cache, rather than the whole mod directory, as the download cache
|
||||
# contains zips that can be unpacked in parallel faster than they can be
|
||||
# fetched and extracted by tar
|
||||
path: |
|
||||
~/.cache/go-build
|
||||
~/go/pkg/mod/cache
|
||||
~\AppData\Local\go-build
|
||||
# The -2- here should be incremented when the scheme of data to be
|
||||
# cached changes (e.g. path above changes).
|
||||
key: ${{ github.job }}-${{ runner.os }}-${{ matrix.goos }}-${{ matrix.goarch }}-go-2-${{ hashFiles('**/go.sum') }}-${{ github.run_id }}
|
||||
restore-keys: |
|
||||
${{ github.job }}-${{ runner.os }}-${{ matrix.goos }}-${{ matrix.goarch }}-go-2-${{ hashFiles('**/go.sum') }}
|
||||
${{ github.job }}-${{ runner.os }}-${{ matrix.goos }}-${{ matrix.goarch }}-go-2-
|
||||
- name: build all
|
||||
run: ./tool/go build ./cmd/...
|
||||
env:
|
||||
GOOS: ${{ matrix.goos }}
|
||||
GOARCH: ${{ matrix.goarch }}
|
||||
GOARM: ${{ matrix.goarm }}
|
||||
CGO_ENABLED: "0"
|
||||
- name: build tests
|
||||
run: ./tool/go test -exec=true ./...
|
||||
env:
|
||||
GOOS: ${{ matrix.goos }}
|
||||
GOARCH: ${{ matrix.goarch }}
|
||||
CGO_ENABLED: "0"
|
||||
|
||||
ios: # similar to cross above, but iOS can't build most of the repo. So, just
|
||||
#make it build a few smoke packages.
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: build some
|
||||
run: ./tool/go build ./ipn/... ./wgengine/ ./types/... ./control/controlclient
|
||||
env:
|
||||
GOOS: ios
|
||||
GOARCH: arm64
|
||||
|
||||
android:
|
||||
# similar to cross above, but android fails to build a few pieces of the
|
||||
# repo. We should fix those pieces, they're small, but as a stepping stone,
|
||||
# only test the subset of android that our past smoke test checked.
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: checkout
|
||||
uses: actions/checkout@v4
|
||||
# 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.
|
||||
# TODO(bradfitz): better; see https://github.com/tailscale/tailscale/issues/4482
|
||||
- name: build some
|
||||
run: ./tool/go install ./net/netns ./ipn/ipnlocal ./wgengine/magicsock/ ./wgengine/ ./wgengine/router/ ./wgengine/netstack ./util/dnsname/ ./ipn/ ./net/interfaces ./wgengine/router/ ./tailcfg/ ./types/logger/ ./net/dns ./hostinfo ./version
|
||||
env:
|
||||
GOOS: android
|
||||
GOARCH: arm64
|
||||
|
||||
wasm: # builds tsconnect, which is the only wasm build we support
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: Restore Cache
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
# Note: unlike the other setups, this is only grabbing the mod download
|
||||
# cache, rather than the whole mod directory, as the download cache
|
||||
# contains zips that can be unpacked in parallel faster than they can be
|
||||
# fetched and extracted by tar
|
||||
path: |
|
||||
~/.cache/go-build
|
||||
~/go/pkg/mod/cache
|
||||
~\AppData\Local\go-build
|
||||
# The -2- here should be incremented when the scheme of data to be
|
||||
# cached changes (e.g. path above changes).
|
||||
key: ${{ github.job }}-${{ runner.os }}-go-2-${{ hashFiles('**/go.sum') }}-${{ github.run_id }}
|
||||
restore-keys: |
|
||||
${{ github.job }}-${{ runner.os }}-go-2-${{ hashFiles('**/go.sum') }}
|
||||
${{ github.job }}-${{ runner.os }}-go-2-
|
||||
- name: build tsconnect client
|
||||
run: ./tool/go build ./cmd/tsconnect/wasm ./cmd/tailscale/cli
|
||||
env:
|
||||
GOOS: js
|
||||
GOARCH: wasm
|
||||
- name: build tsconnect server
|
||||
# Note, no GOOS/GOARCH in env on this build step, we're running a build
|
||||
# tool that handles the build itself.
|
||||
run: |
|
||||
./tool/go run ./cmd/tsconnect --fast-compression build
|
||||
./tool/go run ./cmd/tsconnect --fast-compression build-pkg
|
||||
|
||||
tailscale_go: # Subset of tests that depend on our custom Go toolchain.
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: test tailscale_go
|
||||
run: ./tool/go test -tags=tailscale_go,ts_enable_sockstats ./net/sockstats/...
|
||||
|
||||
|
||||
fuzz:
|
||||
# This target periodically breaks (see TS_FUZZ_CURRENTLY_BROKEN at the top
|
||||
# of the file), so it's more complex than usual: the 'build fuzzers' step
|
||||
# might fail, and depending on the value of 'TS_FUZZ_CURRENTLY_BROKEN', that
|
||||
# might or might not be fine. The steps after the build figure out whether
|
||||
# the success/failure is expected, and appropriately pass/fail the job
|
||||
# overall accordingly.
|
||||
#
|
||||
# Practically, this means that all steps after 'build fuzzers' must have an
|
||||
# explicit 'if' condition, because the default condition for steps is
|
||||
# 'success()', meaning "only run this if no previous steps failed".
|
||||
if: github.event_name == 'pull_request'
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: build fuzzers
|
||||
id: build
|
||||
uses: google/oss-fuzz/infra/cifuzz/actions/build_fuzzers@master
|
||||
# continue-on-error makes steps.build.conclusion be 'success' even if
|
||||
# steps.build.outcome is 'failure'. This means this step does not
|
||||
# contribute to the job's overall pass/fail evaluation.
|
||||
continue-on-error: true
|
||||
with:
|
||||
oss-fuzz-project-name: 'tailscale'
|
||||
dry-run: false
|
||||
language: go
|
||||
- name: report unexpectedly broken fuzz build
|
||||
if: steps.build.outcome == 'failure' && env.TS_FUZZ_CURRENTLY_BROKEN != 'true'
|
||||
run: |
|
||||
echo "fuzzer build failed, see above for why"
|
||||
echo "if the failure is due to OSS-Fuzz not being on the latest Go yet,"
|
||||
echo "set TS_FUZZ_CURRENTLY_BROKEN=true in .github/workflows/test.yml"
|
||||
echo "to temporarily disable fuzzing until OSS-Fuzz works again."
|
||||
exit 1
|
||||
- name: report unexpectedly working fuzz build
|
||||
if: steps.build.outcome == 'success' && env.TS_FUZZ_CURRENTLY_BROKEN == 'true'
|
||||
run: |
|
||||
echo "fuzzer build succeeded, but we expect it to be broken"
|
||||
echo "please set TS_FUZZ_CURRENTLY_BROKEN=false in .github/workflows/test.yml"
|
||||
echo "to reenable fuzz testing"
|
||||
exit 1
|
||||
- name: run fuzzers
|
||||
id: run
|
||||
# Run the fuzzers whenever they're able to build, even if we're going to
|
||||
# report a failure because TS_FUZZ_CURRENTLY_BROKEN is set to the wrong
|
||||
# value.
|
||||
if: steps.build.outcome == 'success'
|
||||
uses: google/oss-fuzz/infra/cifuzz/actions/run_fuzzers@master
|
||||
with:
|
||||
oss-fuzz-project-name: 'tailscale'
|
||||
fuzz-seconds: 300
|
||||
dry-run: false
|
||||
language: go
|
||||
- name: upload crash
|
||||
uses: actions/upload-artifact@v3
|
||||
if: steps.run.outcome != 'success' && steps.build.outcome == 'success'
|
||||
with:
|
||||
name: artifacts
|
||||
path: ./out/artifacts
|
||||
|
||||
depaware:
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: check depaware
|
||||
run: |
|
||||
export PATH=$(./tool/go env GOROOT)/bin:$PATH
|
||||
find . -name 'depaware.txt' | xargs -n1 dirname | xargs ./tool/go run github.com/tailscale/depaware --check
|
||||
|
||||
go_generate:
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: check that 'go generate' is clean
|
||||
run: |
|
||||
pkgs=$(./tool/go list ./... | grep -v dnsfallback)
|
||||
./tool/go generate $pkgs
|
||||
echo
|
||||
echo
|
||||
git diff --name-only --exit-code || (echo "The files above need updating. Please run 'go generate'."; exit 1)
|
||||
|
||||
go_mod_tidy:
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: check that 'go mod tidy' is clean
|
||||
run: |
|
||||
./tool/go mod tidy
|
||||
echo
|
||||
echo
|
||||
git diff --name-only --exit-code || (echo "Please run 'go mod tidy'."; exit 1)
|
||||
|
||||
licenses:
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: check licenses
|
||||
run: ./scripts/check_license_headers.sh .
|
||||
|
||||
staticcheck:
|
||||
runs-on: ubuntu-22.04
|
||||
strategy:
|
||||
fail-fast: false # don't abort the entire matrix if one element fails
|
||||
matrix:
|
||||
goos: ["linux", "windows", "darwin"]
|
||||
goarch: ["amd64"]
|
||||
include:
|
||||
- goos: "windows"
|
||||
goarch: "386"
|
||||
steps:
|
||||
- name: checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: install staticcheck
|
||||
run: GOBIN=~/.local/bin ./tool/go install honnef.co/go/tools/cmd/staticcheck
|
||||
- name: run staticcheck
|
||||
run: |
|
||||
export GOROOT=$(./tool/go env GOROOT)
|
||||
export PATH=$GOROOT/bin:$PATH
|
||||
staticcheck -- $(./tool/go list ./... | grep -v tempfork)
|
||||
env:
|
||||
GOOS: ${{ matrix.goos }}
|
||||
GOARCH: ${{ matrix.goarch }}
|
||||
|
||||
notify_slack:
|
||||
if: always()
|
||||
# Any of these jobs failing causes a slack notification.
|
||||
needs:
|
||||
- android
|
||||
- test
|
||||
- windows
|
||||
- vm
|
||||
- cross
|
||||
- ios
|
||||
- wasm
|
||||
- tailscale_go
|
||||
- fuzz
|
||||
- depaware
|
||||
- go_generate
|
||||
- go_mod_tidy
|
||||
- licenses
|
||||
- staticcheck
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: notify
|
||||
# Only notify slack for merged commits, not PR failures.
|
||||
#
|
||||
# It may be tempting to move this condition into the job's 'if' block, but
|
||||
# don't: Github only collapses the test list into "everything is OK" if
|
||||
# all jobs succeeded. A skipped job results in the list staying expanded.
|
||||
# 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
|
||||
with:
|
||||
payload: |
|
||||
{
|
||||
"attachments": [{
|
||||
"title": "Failure: ${{ github.workflow }}",
|
||||
"title_link": "https://github.com/${{ github.repository }}/commit/${{ github.sha }}/checks",
|
||||
"text": "${{ github.repository }}@${{ github.ref_name }}: <https://github.com/${{ github.repository }}/commit/${{ github.sha }}|${{ github.sha }}>",
|
||||
"fields": [{ "value": ${{ toJson(github.event.head_commit.message) }}, "short": false }],
|
||||
"footer": "${{ github.event.head_commit.committer.name }} at ${{ github.event.head_commit.timestamp }}",
|
||||
"color": "danger"
|
||||
}]
|
||||
}
|
||||
env:
|
||||
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
|
||||
|
||||
check_mergeability:
|
||||
if: always()
|
||||
runs-on: ubuntu-22.04
|
||||
needs:
|
||||
- android
|
||||
- test
|
||||
- windows
|
||||
- vm
|
||||
- cross
|
||||
- ios
|
||||
- wasm
|
||||
- tailscale_go
|
||||
- fuzz
|
||||
- depaware
|
||||
- go_generate
|
||||
- go_mod_tidy
|
||||
- licenses
|
||||
- staticcheck
|
||||
steps:
|
||||
- name: Decide if change is okay to merge
|
||||
if: github.event_name != 'push'
|
||||
uses: re-actors/alls-green@release/v1
|
||||
with:
|
||||
jobs: ${{ toJSON(needs) }}
|
||||
49
.github/workflows/update-flake.yml
vendored
Normal file
49
.github/workflows/update-flake.yml
vendored
Normal file
@@ -0,0 +1,49 @@
|
||||
name: update-flake
|
||||
|
||||
on:
|
||||
# run action when a change lands in the main branch which updates go.mod. Also
|
||||
# allow manual triggering.
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- go.mod
|
||||
- .github/workflows/update-flakes.yml
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-$${{ github.head_ref || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
update-flake:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Run update-flakes
|
||||
run: ./update-flake.sh
|
||||
|
||||
- name: Get access token
|
||||
uses: tibdex/github-app-token@b62528385c34dbc9f38e5f4225ac829252d1ea92 # v1.8.0
|
||||
id: generate-token
|
||||
with:
|
||||
app_id: ${{ secrets.LICENSING_APP_ID }}
|
||||
installation_id: ${{ 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
|
||||
with:
|
||||
token: ${{ steps.generate-token.outputs.token }}
|
||||
author: Flakes Updater <noreply+flakes-updater@tailscale.com>
|
||||
committer: Flakes Updater <noreply+flakes-updater@tailscale.com>
|
||||
branch: flakes
|
||||
commit-message: "go.mod.sri: update SRI hash for go.mod changes"
|
||||
title: "go.mod.sri: update SRI hash for go.mod changes"
|
||||
body: Triggered by ${{ github.repository }}@${{ github.sha }}
|
||||
signoff: true
|
||||
delete-branch: true
|
||||
reviewers: danderson
|
||||
44
.github/workflows/vm.yml
vendored
44
.github/workflows/vm.yml
vendored
@@ -1,44 +0,0 @@
|
||||
name: VM
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- '*'
|
||||
|
||||
jobs:
|
||||
ubuntu2004-LTS-cloud-base:
|
||||
runs-on: [ self-hosted, linux, vm ]
|
||||
|
||||
if: "!contains(github.event.head_commit.message, '[ci skip]')"
|
||||
|
||||
steps:
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v1
|
||||
with:
|
||||
go-version: 1.17
|
||||
id: go
|
||||
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@v1
|
||||
|
||||
- name: Run VM tests
|
||||
run: go test ./tstest/integration/vms -v -no-s3 -run-vm-tests -run=TestRunUbuntu2004
|
||||
env:
|
||||
HOME: "/tmp"
|
||||
TMPDIR: "/tmp"
|
||||
XDG_CACHE_HOME: "/var/lib/ghrunner/cache"
|
||||
|
||||
- uses: k0kubun/action-slack@v2.0.0
|
||||
with:
|
||||
payload: |
|
||||
{
|
||||
"attachments": [{
|
||||
"text": "${{ job.status }}: ${{ github.workflow }} <https://github.com/${{ github.repository }}/commit/${{ github.sha }}/checks|${{ env.COMMIT_DATE }} #${{ env.COMMIT_NUMBER_OF_DAY }}> " +
|
||||
"(<https://github.com/${{ github.repository }}/commit/${{ github.sha }}|" + "${{ github.sha }}".substring(0, 10) + ">) " +
|
||||
"of ${{ github.repository }}@" + "${{ github.ref }}".split('/').reverse()[0] + " by ${{ github.event.head_commit.committer.name }}",
|
||||
"color": "danger"
|
||||
}]
|
||||
}
|
||||
env:
|
||||
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
|
||||
if: failure() && github.event_name == 'push'
|
||||
55
.github/workflows/windows-race.yml
vendored
55
.github/workflows/windows-race.yml
vendored
@@ -1,55 +0,0 @@
|
||||
name: Windows race
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
branches:
|
||||
- '*'
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: windows-latest
|
||||
|
||||
if: "!contains(github.event.head_commit.message, '[ci skip]')"
|
||||
|
||||
steps:
|
||||
|
||||
- name: Install Go
|
||||
uses: actions/setup-go@v2.1.4
|
||||
with:
|
||||
go-version: 1.17.x
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v2
|
||||
|
||||
- name: Restore Cache
|
||||
uses: actions/cache@v2
|
||||
with:
|
||||
path: ~/go/pkg/mod
|
||||
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-go-
|
||||
|
||||
- name: Test with -race flag
|
||||
# Don't use -bench=. -benchtime=1x.
|
||||
# Somewhere in the layers (powershell?)
|
||||
# the equals signs cause great confusion.
|
||||
run: go test -race -bench . -benchtime 1x ./...
|
||||
|
||||
- uses: k0kubun/action-slack@v2.0.0
|
||||
with:
|
||||
payload: |
|
||||
{
|
||||
"attachments": [{
|
||||
"text": "${{ job.status }}: ${{ github.workflow }} <https://github.com/${{ github.repository }}/commit/${{ github.sha }}/checks|${{ env.COMMIT_DATE }} #${{ env.COMMIT_NUMBER_OF_DAY }}> " +
|
||||
"(<https://github.com/${{ github.repository }}/commit/${{ github.sha }}|" + "${{ github.sha }}".substring(0, 10) + ">) " +
|
||||
"of ${{ github.repository }}@" + "${{ github.ref }}".split('/').reverse()[0] + " by ${{ github.event.head_commit.committer.name }}",
|
||||
"color": "danger"
|
||||
}]
|
||||
}
|
||||
env:
|
||||
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
|
||||
if: failure() && github.event_name == 'push'
|
||||
|
||||
55
.github/workflows/windows.yml
vendored
55
.github/workflows/windows.yml
vendored
@@ -1,55 +0,0 @@
|
||||
name: Windows
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
branches:
|
||||
- '*'
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: windows-latest
|
||||
|
||||
if: "!contains(github.event.head_commit.message, '[ci skip]')"
|
||||
|
||||
steps:
|
||||
|
||||
- name: Install Go
|
||||
uses: actions/setup-go@v2.1.4
|
||||
with:
|
||||
go-version: 1.17.x
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v2
|
||||
|
||||
- name: Restore Cache
|
||||
uses: actions/cache@v2
|
||||
with:
|
||||
path: ~/go/pkg/mod
|
||||
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-go-
|
||||
|
||||
- name: Test
|
||||
# Don't use -bench=. -benchtime=1x.
|
||||
# Somewhere in the layers (powershell?)
|
||||
# the equals signs cause great confusion.
|
||||
run: go test -bench . -benchtime 1x ./...
|
||||
|
||||
- uses: k0kubun/action-slack@v2.0.0
|
||||
with:
|
||||
payload: |
|
||||
{
|
||||
"attachments": [{
|
||||
"text": "${{ job.status }}: ${{ github.workflow }} <https://github.com/${{ github.repository }}/commit/${{ github.sha }}/checks|${{ env.COMMIT_DATE }} #${{ env.COMMIT_NUMBER_OF_DAY }}> " +
|
||||
"(<https://github.com/${{ github.repository }}/commit/${{ github.sha }}|" + "${{ github.sha }}".substring(0, 10) + ">) " +
|
||||
"of ${{ github.repository }}@" + "${{ github.ref }}".split('/').reverse()[0] + " by ${{ github.event.head_commit.committer.name }}",
|
||||
"color": "danger"
|
||||
}]
|
||||
}
|
||||
env:
|
||||
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
|
||||
if: failure() && github.event_name == 'push'
|
||||
|
||||
21
.gitignore
vendored
21
.gitignore
vendored
@@ -5,6 +5,7 @@
|
||||
*.dll
|
||||
*.so
|
||||
*.dylib
|
||||
*.spk
|
||||
|
||||
cmd/tailscale/tailscale
|
||||
cmd/tailscaled/tailscaled
|
||||
@@ -21,3 +22,23 @@ cmd/tailscaled/tailscaled
|
||||
# direnv config, this may be different for other people so it's probably safer
|
||||
# to make this nonspecific.
|
||||
.envrc
|
||||
|
||||
# Ignore personal VS Code settings
|
||||
.vscode/
|
||||
|
||||
# Support personal project-specific GOPATH
|
||||
.gopath/
|
||||
|
||||
# Ignore nix build result path
|
||||
/result
|
||||
|
||||
# Ignore direnv nix-shell environment cache
|
||||
.direnv/
|
||||
|
||||
# Ignore web client node modules
|
||||
.vite/
|
||||
client/web/node_modules
|
||||
client/web/build/assets
|
||||
|
||||
/gocross
|
||||
/dist
|
||||
|
||||
61
.golangci.yml
Normal file
61
.golangci.yml
Normal file
@@ -0,0 +1,61 @@
|
||||
linters:
|
||||
# Don't enable any linters by default; just the ones that we explicitly
|
||||
# enable in the list below.
|
||||
disable-all: true
|
||||
enable:
|
||||
- bidichk
|
||||
- gofmt
|
||||
- goimports
|
||||
- misspell
|
||||
- revive
|
||||
|
||||
# Configuration for how we run golangci-lint
|
||||
run:
|
||||
timeout: 5m
|
||||
|
||||
issues:
|
||||
# Excluding configuration per-path, per-linter, per-text and per-source
|
||||
exclude-rules:
|
||||
# These are forks of an upstream package and thus are exempt from stylistic
|
||||
# changes that would make pulling in upstream changes harder.
|
||||
- path: tempfork/.*\.go
|
||||
text: "File is not `gofmt`-ed with `-s` `-r 'interface{} -> any'`"
|
||||
- path: util/singleflight/.*\.go
|
||||
text: "File is not `gofmt`-ed with `-s` `-r 'interface{} -> any'`"
|
||||
|
||||
# Per-linter settings are contained in this top-level key
|
||||
linters-settings:
|
||||
# Enable all rules by default; we don't use invisible unicode runes.
|
||||
bidichk:
|
||||
|
||||
gofmt:
|
||||
rewrite-rules:
|
||||
- pattern: 'interface{}'
|
||||
replacement: 'any'
|
||||
|
||||
goimports:
|
||||
|
||||
misspell:
|
||||
|
||||
revive:
|
||||
enable-all-rules: false
|
||||
ignore-generated-header: true
|
||||
rules:
|
||||
- name: atomic
|
||||
- name: context-keys-type
|
||||
- name: defer
|
||||
arguments: [[
|
||||
# Calling 'recover' at the time a defer is registered (i.e. "defer recover()") has no effect.
|
||||
"immediate-recover",
|
||||
# Calling 'recover' outside of a deferred function has no effect
|
||||
"recover",
|
||||
# Returning values from a deferred function has no effect
|
||||
"return",
|
||||
]]
|
||||
- name: duplicated-imports
|
||||
- name: errorf
|
||||
- name: string-of-int
|
||||
- name: time-equal
|
||||
- name: unconditional-recursion
|
||||
- name: useless-break
|
||||
- name: waitgroup-by-value
|
||||
1
ALPINE.txt
Normal file
1
ALPINE.txt
Normal file
@@ -0,0 +1 @@
|
||||
3.16
|
||||
1
CODEOWNERS
Normal file
1
CODEOWNERS
Normal file
@@ -0,0 +1 @@
|
||||
/tailcfg/ @tailscale/control-protocol-owners
|
||||
35
Dockerfile
35
Dockerfile
@@ -1,6 +1,5 @@
|
||||
# Copyright (c) 2020 Tailscale Inc & AUTHORS All rights reserved.
|
||||
# Use of this source code is governed by a BSD-style
|
||||
# license that can be found in the LICENSE file.
|
||||
# Copyright (c) Tailscale Inc & AUTHORS
|
||||
# SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
############################################################################
|
||||
#
|
||||
@@ -32,13 +31,24 @@
|
||||
# $ docker exec tailscaled tailscale status
|
||||
|
||||
|
||||
FROM golang:1.17-alpine AS build-env
|
||||
FROM golang:1.21-alpine AS build-env
|
||||
|
||||
WORKDIR /go/src/tailscale
|
||||
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
# Pre-build some stuff before the following COPY line invalidates the Docker cache.
|
||||
RUN go install \
|
||||
github.com/aws/aws-sdk-go-v2/aws \
|
||||
github.com/aws/aws-sdk-go-v2/config \
|
||||
gvisor.dev/gvisor/pkg/tcpip/adapters/gonet \
|
||||
gvisor.dev/gvisor/pkg/tcpip/stack \
|
||||
golang.org/x/crypto/ssh \
|
||||
golang.org/x/crypto/acme \
|
||||
nhooyr.io/websocket \
|
||||
github.com/mdlayher/netlink
|
||||
|
||||
COPY . .
|
||||
|
||||
# see build_docker.sh
|
||||
@@ -50,11 +60,16 @@ ARG VERSION_GIT_HASH=""
|
||||
ENV VERSION_GIT_HASH=$VERSION_GIT_HASH
|
||||
ARG TARGETARCH
|
||||
|
||||
RUN GOARCH=$TARGETARCH go install -tags=xversion -ldflags="\
|
||||
-X tailscale.com/version.Long=$VERSION_LONG \
|
||||
-X tailscale.com/version.Short=$VERSION_SHORT \
|
||||
-X tailscale.com/version.GitCommit=$VERSION_GIT_HASH" \
|
||||
-v ./cmd/tailscale ./cmd/tailscaled
|
||||
RUN GOARCH=$TARGETARCH go install -ldflags="\
|
||||
-X tailscale.com/version.longStamp=$VERSION_LONG \
|
||||
-X tailscale.com/version.shortStamp=$VERSION_SHORT \
|
||||
-X tailscale.com/version.gitCommitStamp=$VERSION_GIT_HASH" \
|
||||
-v ./cmd/tailscale ./cmd/tailscaled ./cmd/containerboot
|
||||
|
||||
FROM alpine:3.16
|
||||
RUN apk add --no-cache ca-certificates iptables iproute2 ip6tables
|
||||
|
||||
FROM ghcr.io/tailscale/alpine-base:3.14
|
||||
COPY --from=build-env /go/bin/* /usr/local/bin/
|
||||
# For compat with the previous run.sh, although ideally you should be
|
||||
# using build_docker.sh which sets an entrypoint for the image.
|
||||
RUN mkdir /tailscale && ln -s /usr/local/bin/containerboot /tailscale/run.sh
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
# Copyright (c) 2021 Tailscale Inc & AUTHORS All rights reserved.
|
||||
# Use of this source code is governed by a BSD-style
|
||||
# license that can be found in the LICENSE file.
|
||||
# Copyright (c) Tailscale Inc & AUTHORS
|
||||
# SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
FROM alpine:3.14
|
||||
RUN apk add --no-cache ca-certificates iptables iproute2 ip6tables
|
||||
FROM alpine:3.16
|
||||
RUN apk add --no-cache ca-certificates iptables iproute2 ip6tables iputils
|
||||
|
||||
3
LICENSE
3
LICENSE
@@ -1,7 +1,6 @@
|
||||
BSD 3-Clause License
|
||||
|
||||
Copyright (c) 2020 Tailscale & AUTHORS.
|
||||
All rights reserved.
|
||||
Copyright (c) 2020 Tailscale Inc & AUTHORS.
|
||||
|
||||
Redistribution and use in source and binary forms, with or without
|
||||
modification, are permitted provided that the following conditions are met:
|
||||
|
||||
99
Makefile
99
Makefile
@@ -1,40 +1,87 @@
|
||||
IMAGE_REPO ?= tailscale/tailscale
|
||||
SYNO_ARCH ?= "amd64"
|
||||
SYNO_DSM ?= "7"
|
||||
TAGS ?= "latest"
|
||||
|
||||
usage:
|
||||
echo "See Makefile"
|
||||
vet: ## Run go vet
|
||||
./tool/go vet ./...
|
||||
|
||||
vet:
|
||||
go vet ./...
|
||||
tidy: ## Run go mod tidy
|
||||
./tool/go mod tidy
|
||||
|
||||
updatedeps:
|
||||
go run github.com/tailscale/depaware --update tailscale.com/cmd/tailscaled
|
||||
go run github.com/tailscale/depaware --update tailscale.com/cmd/tailscale
|
||||
updatedeps: ## Update depaware deps
|
||||
# depaware (via x/tools/go/packages) shells back to "go", so make sure the "go"
|
||||
# it finds in its $$PATH is the right one.
|
||||
PATH="$$(./tool/go env GOROOT)/bin:$$PATH" ./tool/go run github.com/tailscale/depaware --update \
|
||||
tailscale.com/cmd/tailscaled \
|
||||
tailscale.com/cmd/tailscale \
|
||||
tailscale.com/cmd/derper
|
||||
|
||||
depaware:
|
||||
go run github.com/tailscale/depaware --check tailscale.com/cmd/tailscaled
|
||||
go run github.com/tailscale/depaware --check tailscale.com/cmd/tailscale
|
||||
depaware: ## Run depaware checks
|
||||
# depaware (via x/tools/go/packages) shells back to "go", so make sure the "go"
|
||||
# it finds in its $$PATH is the right one.
|
||||
PATH="$$(./tool/go env GOROOT)/bin:$$PATH" ./tool/go run github.com/tailscale/depaware --check \
|
||||
tailscale.com/cmd/tailscaled \
|
||||
tailscale.com/cmd/tailscale \
|
||||
tailscale.com/cmd/derper
|
||||
|
||||
buildwindows:
|
||||
GOOS=windows GOARCH=amd64 go install tailscale.com/cmd/tailscale tailscale.com/cmd/tailscaled
|
||||
buildwindows: ## Build tailscale CLI for windows/amd64
|
||||
GOOS=windows GOARCH=amd64 ./tool/go install tailscale.com/cmd/tailscale tailscale.com/cmd/tailscaled
|
||||
|
||||
build386:
|
||||
GOOS=linux GOARCH=386 go install tailscale.com/cmd/tailscale tailscale.com/cmd/tailscaled
|
||||
build386: ## Build tailscale CLI for linux/386
|
||||
GOOS=linux GOARCH=386 ./tool/go install tailscale.com/cmd/tailscale tailscale.com/cmd/tailscaled
|
||||
|
||||
buildlinuxarm:
|
||||
GOOS=linux GOARCH=arm go install tailscale.com/cmd/tailscale tailscale.com/cmd/tailscaled
|
||||
buildlinuxarm: ## Build tailscale CLI for linux/arm
|
||||
GOOS=linux GOARCH=arm ./tool/go install tailscale.com/cmd/tailscale tailscale.com/cmd/tailscaled
|
||||
|
||||
buildmultiarchimage:
|
||||
buildwasm: ## Build tailscale CLI for js/wasm
|
||||
GOOS=js GOARCH=wasm ./tool/go install ./cmd/tsconnect/wasm ./cmd/tailscale/cli
|
||||
|
||||
buildplan9:
|
||||
GOOS=plan9 GOARCH=amd64 ./tool/go install ./cmd/tailscale ./cmd/tailscaled
|
||||
|
||||
buildlinuxloong64: ## Build tailscale CLI for linux/loong64
|
||||
GOOS=linux GOARCH=loong64 ./tool/go install tailscale.com/cmd/tailscale tailscale.com/cmd/tailscaled
|
||||
|
||||
buildmultiarchimage: ## Build (and optionally push) multiarch docker image
|
||||
./build_docker.sh
|
||||
|
||||
check: staticcheck vet depaware buildwindows build386 buildlinuxarm
|
||||
check: staticcheck vet depaware buildwindows build386 buildlinuxarm buildwasm ## Perform basic checks and compilation tests
|
||||
|
||||
staticcheck:
|
||||
go run honnef.co/go/tools/cmd/staticcheck -- $$(go list ./... | grep -v tempfork)
|
||||
staticcheck: ## Run staticcheck.io checks
|
||||
./tool/go run honnef.co/go/tools/cmd/staticcheck -- $$(./tool/go list ./... | grep -v tempfork)
|
||||
|
||||
spk:
|
||||
go run github.com/tailscale/tailscale-synology@main --version=build -o tailscale.spk --source=.
|
||||
spk: ## Build synology package for ${SYNO_ARCH} architecture and ${SYNO_DSM} DSM version
|
||||
./tool/go run ./cmd/dist build synology/dsm${SYNO_DSM}/${SYNO_ARCH}
|
||||
|
||||
pushspk: spk
|
||||
echo "Pushing SPKG to root@${SYNOHOST} (env var SYNOHOST) ..."
|
||||
scp tailscale.spk root@${SYNOHOST}:
|
||||
ssh root@${SYNOHOST} /usr/syno/bin/synopkg install tailscale.spk
|
||||
spkall: ## Build synology packages for all architectures and DSM versions
|
||||
./tool/go run ./cmd/dist build synology
|
||||
|
||||
pushspk: spk ## Push and install synology package on ${SYNO_HOST} host
|
||||
echo "Pushing SPK to root@${SYNO_HOST} (env var SYNO_HOST) ..."
|
||||
scp tailscale.spk root@${SYNO_HOST}:
|
||||
ssh root@${SYNO_HOST} /usr/syno/bin/synopkg install tailscale.spk
|
||||
|
||||
publishdevimage: ## Build and publish tailscale image to location specified by ${REPO}
|
||||
@test -n "${REPO}" || (echo "REPO=... required; e.g. REPO=ghcr.io/${USER}/tailscale" && exit 1)
|
||||
@test "${REPO}" != "tailscale/tailscale" || (echo "REPO=... must not be tailscale/tailscale" && exit 1)
|
||||
@test "${REPO}" != "ghcr.io/tailscale/tailscale" || (echo "REPO=... must not be ghcr.io/tailscale/tailscale" && exit 1)
|
||||
@test "${REPO}" != "tailscale/k8s-operator" || (echo "REPO=... must not be tailscale/k8s-operator" && exit 1)
|
||||
@test "${REPO}" != "ghcr.io/tailscale/k8s-operator" || (echo "REPO=... must not be ghcr.io/tailscale/k8s-operator" && exit 1)
|
||||
TAGS="${TAGS}" REPOS=${REPO} PUSH=true TARGET=client ./build_docker.sh
|
||||
|
||||
publishdevoperator: ## Build and publish k8s-operator image to location specified by ${REPO}
|
||||
@test -n "${REPO}" || (echo "REPO=... required; e.g. REPO=ghcr.io/${USER}/tailscale" && exit 1)
|
||||
@test "${REPO}" != "tailscale/tailscale" || (echo "REPO=... must not be tailscale/tailscale" && exit 1)
|
||||
@test "${REPO}" != "ghcr.io/tailscale/tailscale" || (echo "REPO=... must not be ghcr.io/tailscale/tailscale" && exit 1)
|
||||
@test "${REPO}" != "tailscale/k8s-operator" || (echo "REPO=... must not be tailscale/k8s-operator" && exit 1)
|
||||
@test "${REPO}" != "ghcr.io/tailscale/k8s-operator" || (echo "REPO=... must not be ghcr.io/tailscale/k8s-operator" && exit 1)
|
||||
TAGS="${TAGS}" REPOS=${REPO} PUSH=true TARGET=operator ./build_docker.sh
|
||||
|
||||
help: ## Show this help
|
||||
@echo "\nSpecify a command. The choices are:\n"
|
||||
@grep -hE '^[0-9a-zA-Z_-]+:.*?## .*$$' ${MAKEFILE_LIST} | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[0;36m%-20s\033[m %s\n", $$1, $$2}'
|
||||
@echo ""
|
||||
.PHONY: help
|
||||
|
||||
.DEFAULT_GOAL := help
|
||||
|
||||
38
README.md
38
README.md
@@ -6,27 +6,41 @@ Private WireGuard® networks made easy
|
||||
|
||||
## Overview
|
||||
|
||||
This repository contains all the open source Tailscale client code and
|
||||
the `tailscaled` daemon and `tailscale` CLI tool. The `tailscaled`
|
||||
daemon runs on Linux, Windows and [macOS](https://tailscale.com/kb/1065/macos-variants/), and to varying degrees on FreeBSD, OpenBSD, and Darwin. (The Tailscale iOS and Android apps use this repo's code, but this repo doesn't contain the mobile GUI code.)
|
||||
This repository contains the majority of Tailscale's open source code.
|
||||
Notably, it includes the `tailscaled` daemon and
|
||||
the `tailscale` CLI tool. The `tailscaled` daemon runs on Linux, Windows,
|
||||
[macOS](https://tailscale.com/kb/1065/macos-variants/), and to varying degrees
|
||||
on FreeBSD and OpenBSD. The Tailscale iOS and Android apps use this repo's
|
||||
code, but this repo doesn't contain the mobile GUI code.
|
||||
|
||||
The Android app is at https://github.com/tailscale/tailscale-android
|
||||
Other [Tailscale repos](https://github.com/orgs/tailscale/repositories) of note:
|
||||
|
||||
The Synology package is at https://github.com/tailscale/tailscale-synology
|
||||
* the Android app is at https://github.com/tailscale/tailscale-android
|
||||
* the Synology package is at https://github.com/tailscale/tailscale-synology
|
||||
* the QNAP package is at https://github.com/tailscale/tailscale-qpkg
|
||||
* the Chocolatey packaging is at https://github.com/tailscale/tailscale-chocolatey
|
||||
|
||||
For background on which parts of Tailscale are open source and why,
|
||||
see [https://tailscale.com/opensource/](https://tailscale.com/opensource/).
|
||||
|
||||
## Using
|
||||
|
||||
We serve packages for a variety of distros at
|
||||
https://pkgs.tailscale.com .
|
||||
We serve packages for a variety of distros and platforms at
|
||||
[https://pkgs.tailscale.com](https://pkgs.tailscale.com/).
|
||||
|
||||
## Other clients
|
||||
|
||||
The [macOS, iOS, and Windows clients](https://tailscale.com/download)
|
||||
use the code in this repository but additionally include small GUI
|
||||
wrappers that are not open source.
|
||||
wrappers. The GUI wrappers on non-open source platforms are themselves
|
||||
not open source.
|
||||
|
||||
## Building
|
||||
|
||||
We always require the latest Go release, currently Go 1.21. (While we build
|
||||
releases with our [Go fork](https://github.com/tailscale/go/), its use is not
|
||||
required.)
|
||||
|
||||
```
|
||||
go install tailscale.com/cmd/tailscale{,d}
|
||||
```
|
||||
@@ -43,11 +57,6 @@ If your distro has conventions that preclude the use of
|
||||
`build_dist.sh`, please do the equivalent of what it does in your
|
||||
distro's way, so that bug reports contain useful version information.
|
||||
|
||||
We only guarantee to support the latest Go release and any Go beta or
|
||||
release candidate builds (currently Go 1.17) in module mode. It might
|
||||
work in earlier Go versions or in GOPATH mode, but we're making no
|
||||
effort to keep those working.
|
||||
|
||||
## Bugs
|
||||
|
||||
Please file any issues about this code or the hosted service on
|
||||
@@ -62,6 +71,9 @@ We require [Developer Certificate of
|
||||
Origin](https://en.wikipedia.org/wiki/Developer_Certificate_of_Origin)
|
||||
`Signed-off-by` lines in commits.
|
||||
|
||||
See `git log` for our commit message style. It's basically the same as
|
||||
[Go's style](https://github.com/golang/go/wiki/CommitMessage).
|
||||
|
||||
## About Us
|
||||
|
||||
[Tailscale](https://tailscale.com/) is primarily developed by the
|
||||
|
||||
@@ -1 +1 @@
|
||||
1.19.0
|
||||
1.51.0
|
||||
|
||||
328
appc/appc.go
Normal file
328
appc/appc.go
Normal file
@@ -0,0 +1,328 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
// Package appc implements App Connectors.
|
||||
package appc
|
||||
|
||||
import (
|
||||
"expvar"
|
||||
"log"
|
||||
"net"
|
||||
"net/netip"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"golang.org/x/net/dns/dnsmessage"
|
||||
"tailscale.com/metrics"
|
||||
"tailscale.com/tailcfg"
|
||||
"tailscale.com/types/appctype"
|
||||
"tailscale.com/types/ipproto"
|
||||
"tailscale.com/types/nettype"
|
||||
"tailscale.com/util/clientmetric"
|
||||
"tailscale.com/util/mak"
|
||||
)
|
||||
|
||||
var tsMBox = dnsmessage.MustNewName("support.tailscale.com.")
|
||||
|
||||
// target describes the predicates which route some inbound
|
||||
// traffic to the app connector to a specific handler.
|
||||
type target struct {
|
||||
Dest netip.Prefix
|
||||
Matching tailcfg.ProtoPortRange
|
||||
}
|
||||
|
||||
// Server implements an App Connector.
|
||||
type Server struct {
|
||||
mu sync.RWMutex // mu guards following fields
|
||||
connectors map[appctype.ConfigID]connector
|
||||
}
|
||||
|
||||
type appcMetrics struct {
|
||||
dnsResponses expvar.Int
|
||||
dnsFailures expvar.Int
|
||||
tcpConns expvar.Int
|
||||
sniConns expvar.Int
|
||||
unhandledConns expvar.Int
|
||||
}
|
||||
|
||||
var getMetrics = sync.OnceValue[*appcMetrics](func() *appcMetrics {
|
||||
m := appcMetrics{}
|
||||
|
||||
stats := new(metrics.Set)
|
||||
stats.Set("tls_sessions", &m.sniConns)
|
||||
clientmetric.NewCounterFunc("sniproxy_tls_sessions", m.sniConns.Value)
|
||||
stats.Set("tcp_sessions", &m.tcpConns)
|
||||
clientmetric.NewCounterFunc("sniproxy_tcp_sessions", m.tcpConns.Value)
|
||||
stats.Set("dns_responses", &m.dnsResponses)
|
||||
clientmetric.NewCounterFunc("sniproxy_dns_responses", m.dnsResponses.Value)
|
||||
stats.Set("dns_failed", &m.dnsFailures)
|
||||
clientmetric.NewCounterFunc("sniproxy_dns_failed", m.dnsFailures.Value)
|
||||
expvar.Publish("sniproxy", stats)
|
||||
|
||||
return &m
|
||||
})
|
||||
|
||||
// Configure applies the provided configuration to the app connector.
|
||||
func (s *Server) Configure(cfg *appctype.AppConnectorConfig) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.connectors = makeConnectorsFromConfig(cfg)
|
||||
}
|
||||
|
||||
// HandleTCPFlow implements tsnet.FallbackTCPHandler.
|
||||
func (s *Server) HandleTCPFlow(src, dst netip.AddrPort) (handler func(net.Conn), intercept bool) {
|
||||
m := getMetrics()
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
for _, c := range s.connectors {
|
||||
if handler, intercept := c.handleTCPFlow(src, dst, m); intercept {
|
||||
return handler, intercept
|
||||
}
|
||||
}
|
||||
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// HandleDNS handles a DNS request to the app connector.
|
||||
func (s *Server) HandleDNS(c nettype.ConnPacketConn) {
|
||||
defer c.Close()
|
||||
c.SetReadDeadline(time.Now().Add(5 * time.Second))
|
||||
m := getMetrics()
|
||||
|
||||
buf := make([]byte, 1500)
|
||||
n, err := c.Read(buf)
|
||||
if err != nil {
|
||||
log.Printf("HandleDNS: read failed: %v\n ", err)
|
||||
m.dnsFailures.Add(1)
|
||||
return
|
||||
}
|
||||
|
||||
addrPortStr := c.LocalAddr().String()
|
||||
host, _, err := net.SplitHostPort(addrPortStr)
|
||||
if err != nil {
|
||||
log.Printf("HandleDNS: bogus addrPort %q", addrPortStr)
|
||||
m.dnsFailures.Add(1)
|
||||
return
|
||||
}
|
||||
localAddr, err := netip.ParseAddr(host)
|
||||
if err != nil {
|
||||
log.Printf("HandleDNS: bogus local address %q", host)
|
||||
m.dnsFailures.Add(1)
|
||||
return
|
||||
}
|
||||
|
||||
var msg dnsmessage.Message
|
||||
err = msg.Unpack(buf[:n])
|
||||
if err != nil {
|
||||
log.Printf("HandleDNS: dnsmessage unpack failed: %v\n ", err)
|
||||
m.dnsFailures.Add(1)
|
||||
return
|
||||
}
|
||||
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
for _, connector := range s.connectors {
|
||||
resp, err := connector.handleDNS(&msg, localAddr)
|
||||
if err != nil {
|
||||
log.Printf("HandleDNS: connector handling failed: %v\n", err)
|
||||
m.dnsFailures.Add(1)
|
||||
return
|
||||
}
|
||||
if len(resp) > 0 {
|
||||
// This connector handled the DNS request
|
||||
_, err = c.Write(resp)
|
||||
if err != nil {
|
||||
log.Printf("HandleDNS: write failed: %v\n", err)
|
||||
m.dnsFailures.Add(1)
|
||||
return
|
||||
}
|
||||
|
||||
m.dnsResponses.Add(1)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// connector describes a logical collection of
|
||||
// services which need to be proxied.
|
||||
type connector struct {
|
||||
Handlers map[target]handler
|
||||
}
|
||||
|
||||
// handleTCPFlow implements tsnet.FallbackTCPHandler.
|
||||
func (c *connector) handleTCPFlow(src, dst netip.AddrPort, m *appcMetrics) (handler func(net.Conn), intercept bool) {
|
||||
for t, h := range c.Handlers {
|
||||
if t.Matching.Proto != 0 && t.Matching.Proto != int(ipproto.TCP) {
|
||||
continue
|
||||
}
|
||||
if !t.Dest.Contains(dst.Addr()) {
|
||||
continue
|
||||
}
|
||||
if !t.Matching.Ports.Contains(dst.Port()) {
|
||||
continue
|
||||
}
|
||||
|
||||
switch h.(type) {
|
||||
case *tcpSNIHandler:
|
||||
m.sniConns.Add(1)
|
||||
case *tcpRoundRobinHandler:
|
||||
m.tcpConns.Add(1)
|
||||
default:
|
||||
log.Printf("handleTCPFlow: unhandled handler type %T", h)
|
||||
}
|
||||
|
||||
return h.Handle, true
|
||||
}
|
||||
|
||||
m.unhandledConns.Add(1)
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// handleDNS returns the DNS response to the given query. If this
|
||||
// connector is unable to handle the request, nil is returned.
|
||||
func (c *connector) handleDNS(req *dnsmessage.Message, localAddr netip.Addr) (response []byte, err error) {
|
||||
for t, h := range c.Handlers {
|
||||
if t.Dest.Contains(localAddr) {
|
||||
return makeDNSResponse(req, h.ReachableOn())
|
||||
}
|
||||
}
|
||||
|
||||
// Did not match, signal 'not handled' to caller
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func makeDNSResponse(req *dnsmessage.Message, reachableIPs []netip.Addr) (response []byte, err error) {
|
||||
buf := make([]byte, 1500)
|
||||
resp := dnsmessage.NewBuilder(buf,
|
||||
dnsmessage.Header{
|
||||
ID: req.Header.ID,
|
||||
Response: true,
|
||||
Authoritative: true,
|
||||
})
|
||||
resp.EnableCompression()
|
||||
|
||||
if len(req.Questions) == 0 {
|
||||
buf, _ = resp.Finish()
|
||||
return buf, nil
|
||||
}
|
||||
q := req.Questions[0]
|
||||
err = resp.StartQuestions()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
resp.Question(q)
|
||||
|
||||
err = resp.StartAnswers()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
switch q.Type {
|
||||
case dnsmessage.TypeAAAA:
|
||||
for _, ip := range reachableIPs {
|
||||
if ip.Is6() {
|
||||
err = resp.AAAAResource(
|
||||
dnsmessage.ResourceHeader{Name: q.Name, Class: q.Class, TTL: 120},
|
||||
dnsmessage.AAAAResource{AAAA: ip.As16()},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
case dnsmessage.TypeA:
|
||||
for _, ip := range reachableIPs {
|
||||
if ip.Is4() {
|
||||
err = resp.AResource(
|
||||
dnsmessage.ResourceHeader{Name: q.Name, Class: q.Class, TTL: 120},
|
||||
dnsmessage.AResource{A: ip.As4()},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
case dnsmessage.TypeSOA:
|
||||
err = resp.SOAResource(
|
||||
dnsmessage.ResourceHeader{Name: q.Name, Class: q.Class, TTL: 120},
|
||||
dnsmessage.SOAResource{NS: q.Name, MBox: tsMBox, Serial: 2023030600,
|
||||
Refresh: 120, Retry: 120, Expire: 120, MinTTL: 60},
|
||||
)
|
||||
case dnsmessage.TypeNS:
|
||||
err = resp.NSResource(
|
||||
dnsmessage.ResourceHeader{Name: q.Name, Class: q.Class, TTL: 120},
|
||||
dnsmessage.NSResource{NS: tsMBox},
|
||||
)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return resp.Finish()
|
||||
}
|
||||
|
||||
type handler interface {
|
||||
// Handle handles the given socket.
|
||||
Handle(c net.Conn)
|
||||
|
||||
// ReachableOn returns the IP addresses this handler is reachable on.
|
||||
ReachableOn() []netip.Addr
|
||||
}
|
||||
|
||||
func installDNATHandler(d *appctype.DNATConfig, out *connector) {
|
||||
// These handlers don't actually do DNAT, they just
|
||||
// proxy the data over the connection.
|
||||
var dialer net.Dialer
|
||||
dialer.Timeout = 5 * time.Second
|
||||
h := tcpRoundRobinHandler{
|
||||
To: d.To,
|
||||
DialContext: dialer.DialContext,
|
||||
ReachableIPs: d.Addrs,
|
||||
}
|
||||
|
||||
for _, addr := range d.Addrs {
|
||||
for _, protoPort := range d.IP {
|
||||
t := target{
|
||||
Dest: netip.PrefixFrom(addr, addr.BitLen()),
|
||||
Matching: protoPort,
|
||||
}
|
||||
|
||||
mak.Set(&out.Handlers, t, handler(&h))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func installSNIHandler(c *appctype.SNIProxyConfig, out *connector) {
|
||||
var dialer net.Dialer
|
||||
dialer.Timeout = 5 * time.Second
|
||||
h := tcpSNIHandler{
|
||||
Allowlist: c.AllowedDomains,
|
||||
DialContext: dialer.DialContext,
|
||||
ReachableIPs: c.Addrs,
|
||||
}
|
||||
|
||||
for _, addr := range c.Addrs {
|
||||
for _, protoPort := range c.IP {
|
||||
t := target{
|
||||
Dest: netip.PrefixFrom(addr, addr.BitLen()),
|
||||
Matching: protoPort,
|
||||
}
|
||||
|
||||
mak.Set(&out.Handlers, t, handler(&h))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func makeConnectorsFromConfig(cfg *appctype.AppConnectorConfig) map[appctype.ConfigID]connector {
|
||||
var connectors map[appctype.ConfigID]connector
|
||||
|
||||
for cID, d := range cfg.DNAT {
|
||||
c := connectors[cID]
|
||||
installDNATHandler(&d, &c)
|
||||
mak.Set(&connectors, cID, c)
|
||||
}
|
||||
for cID, d := range cfg.SNIProxy {
|
||||
c := connectors[cID]
|
||||
installSNIHandler(&d, &c)
|
||||
mak.Set(&connectors, cID, c)
|
||||
}
|
||||
|
||||
return connectors
|
||||
}
|
||||
95
appc/appc_test.go
Normal file
95
appc/appc_test.go
Normal file
@@ -0,0 +1,95 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package appc
|
||||
|
||||
import (
|
||||
"net/netip"
|
||||
"testing"
|
||||
|
||||
"github.com/google/go-cmp/cmp"
|
||||
"github.com/google/go-cmp/cmp/cmpopts"
|
||||
"tailscale.com/tailcfg"
|
||||
"tailscale.com/types/appctype"
|
||||
)
|
||||
|
||||
func TestMakeConnectorsFromConfig(t *testing.T) {
|
||||
tcs := []struct {
|
||||
name string
|
||||
input *appctype.AppConnectorConfig
|
||||
want map[appctype.ConfigID]connector
|
||||
}{
|
||||
{
|
||||
"empty",
|
||||
&appctype.AppConnectorConfig{},
|
||||
nil,
|
||||
},
|
||||
{
|
||||
"DNAT",
|
||||
&appctype.AppConnectorConfig{
|
||||
DNAT: map[appctype.ConfigID]appctype.DNATConfig{
|
||||
"swiggity_swooty": {
|
||||
Addrs: []netip.Addr{netip.MustParseAddr("100.64.0.1"), netip.MustParseAddr("fd7a:115c:a1e0::1")},
|
||||
To: []string{"example.org"},
|
||||
IP: []tailcfg.ProtoPortRange{{Proto: 0, Ports: tailcfg.PortRange{First: 0, Last: 65535}}},
|
||||
},
|
||||
},
|
||||
},
|
||||
map[appctype.ConfigID]connector{
|
||||
"swiggity_swooty": {
|
||||
Handlers: map[target]handler{
|
||||
{
|
||||
Dest: netip.MustParsePrefix("100.64.0.1/32"),
|
||||
Matching: tailcfg.ProtoPortRange{Proto: 0, Ports: tailcfg.PortRange{First: 0, Last: 65535}},
|
||||
}: &tcpRoundRobinHandler{To: []string{"example.org"}, ReachableIPs: []netip.Addr{netip.MustParseAddr("100.64.0.1"), netip.MustParseAddr("fd7a:115c:a1e0::1")}},
|
||||
{
|
||||
Dest: netip.MustParsePrefix("fd7a:115c:a1e0::1/128"),
|
||||
Matching: tailcfg.ProtoPortRange{Proto: 0, Ports: tailcfg.PortRange{First: 0, Last: 65535}},
|
||||
}: &tcpRoundRobinHandler{To: []string{"example.org"}, ReachableIPs: []netip.Addr{netip.MustParseAddr("100.64.0.1"), netip.MustParseAddr("fd7a:115c:a1e0::1")}},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"SNIProxy",
|
||||
&appctype.AppConnectorConfig{
|
||||
SNIProxy: map[appctype.ConfigID]appctype.SNIProxyConfig{
|
||||
"swiggity_swooty": {
|
||||
Addrs: []netip.Addr{netip.MustParseAddr("100.64.0.1"), netip.MustParseAddr("fd7a:115c:a1e0::1")},
|
||||
AllowedDomains: []string{"example.org"},
|
||||
IP: []tailcfg.ProtoPortRange{{Proto: 0, Ports: tailcfg.PortRange{First: 0, Last: 65535}}},
|
||||
},
|
||||
},
|
||||
},
|
||||
map[appctype.ConfigID]connector{
|
||||
"swiggity_swooty": {
|
||||
Handlers: map[target]handler{
|
||||
{
|
||||
Dest: netip.MustParsePrefix("100.64.0.1/32"),
|
||||
Matching: tailcfg.ProtoPortRange{Proto: 0, Ports: tailcfg.PortRange{First: 0, Last: 65535}},
|
||||
}: &tcpSNIHandler{Allowlist: []string{"example.org"}, ReachableIPs: []netip.Addr{netip.MustParseAddr("100.64.0.1"), netip.MustParseAddr("fd7a:115c:a1e0::1")}},
|
||||
{
|
||||
Dest: netip.MustParsePrefix("fd7a:115c:a1e0::1/128"),
|
||||
Matching: tailcfg.ProtoPortRange{Proto: 0, Ports: tailcfg.PortRange{First: 0, Last: 65535}},
|
||||
}: &tcpSNIHandler{Allowlist: []string{"example.org"}, ReachableIPs: []netip.Addr{netip.MustParseAddr("100.64.0.1"), netip.MustParseAddr("fd7a:115c:a1e0::1")}},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tcs {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
connectors := makeConnectorsFromConfig(tc.input)
|
||||
|
||||
if diff := cmp.Diff(connectors, tc.want,
|
||||
cmpopts.IgnoreFields(tcpRoundRobinHandler{}, "DialContext"),
|
||||
cmpopts.IgnoreFields(tcpSNIHandler{}, "DialContext"),
|
||||
cmp.Comparer(func(x, y netip.Addr) bool {
|
||||
return x == y
|
||||
})); diff != "" {
|
||||
t.Fatalf("mismatch (-want +got):\n%s", diff)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
104
appc/handlers.go
Normal file
104
appc/handlers.go
Normal file
@@ -0,0 +1,104 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package appc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"math/rand"
|
||||
"net"
|
||||
"net/netip"
|
||||
"slices"
|
||||
|
||||
"inet.af/tcpproxy"
|
||||
"tailscale.com/net/netutil"
|
||||
)
|
||||
|
||||
type tcpRoundRobinHandler struct {
|
||||
// To is a list of destination addresses to forward to.
|
||||
// An entry may be either an IP address or a DNS name.
|
||||
To []string
|
||||
|
||||
// DialContext is used to make the outgoing TCP connection.
|
||||
DialContext func(ctx context.Context, network, address string) (net.Conn, error)
|
||||
|
||||
// ReachableIPs enumerates the IP addresses this handler is reachable on.
|
||||
ReachableIPs []netip.Addr
|
||||
}
|
||||
|
||||
// ReachableOn returns the IP addresses this handler is reachable on.
|
||||
func (h *tcpRoundRobinHandler) ReachableOn() []netip.Addr {
|
||||
return h.ReachableIPs
|
||||
}
|
||||
|
||||
func (h *tcpRoundRobinHandler) Handle(c net.Conn) {
|
||||
addrPortStr := c.LocalAddr().String()
|
||||
_, port, err := net.SplitHostPort(addrPortStr)
|
||||
if err != nil {
|
||||
log.Printf("tcpRoundRobinHandler.Handle: bogus addrPort %q", addrPortStr)
|
||||
c.Close()
|
||||
return
|
||||
}
|
||||
|
||||
var p tcpproxy.Proxy
|
||||
p.ListenFunc = func(net, laddr string) (net.Listener, error) {
|
||||
return netutil.NewOneConnListener(c, nil), nil
|
||||
}
|
||||
|
||||
dest := h.To[rand.Intn(len(h.To))]
|
||||
dial := &tcpproxy.DialProxy{
|
||||
Addr: fmt.Sprintf("%s:%s", dest, port),
|
||||
DialContext: h.DialContext,
|
||||
}
|
||||
|
||||
p.AddRoute(addrPortStr, dial)
|
||||
p.Start()
|
||||
}
|
||||
|
||||
type tcpSNIHandler struct {
|
||||
// Allowlist enumerates the FQDNs which may be proxied via SNI. An
|
||||
// empty slice means all domains are permitted.
|
||||
Allowlist []string
|
||||
|
||||
// DialContext is used to make the outgoing TCP connection.
|
||||
DialContext func(ctx context.Context, network, address string) (net.Conn, error)
|
||||
|
||||
// ReachableIPs enumerates the IP addresses this handler is reachable on.
|
||||
ReachableIPs []netip.Addr
|
||||
}
|
||||
|
||||
// ReachableOn returns the IP addresses this handler is reachable on.
|
||||
func (h *tcpSNIHandler) ReachableOn() []netip.Addr {
|
||||
return h.ReachableIPs
|
||||
}
|
||||
|
||||
func (h *tcpSNIHandler) Handle(c net.Conn) {
|
||||
addrPortStr := c.LocalAddr().String()
|
||||
_, port, err := net.SplitHostPort(addrPortStr)
|
||||
if err != nil {
|
||||
log.Printf("tcpSNIHandler.Handle: bogus addrPort %q", addrPortStr)
|
||||
c.Close()
|
||||
return
|
||||
}
|
||||
|
||||
var p tcpproxy.Proxy
|
||||
p.ListenFunc = func(net, laddr string) (net.Listener, error) {
|
||||
return netutil.NewOneConnListener(c, nil), nil
|
||||
}
|
||||
p.AddSNIRouteFunc(addrPortStr, func(ctx context.Context, sniName string) (t tcpproxy.Target, ok bool) {
|
||||
if len(h.Allowlist) > 0 {
|
||||
// TODO(tom): handle subdomains
|
||||
if slices.Index(h.Allowlist, sniName) < 0 {
|
||||
return nil, false
|
||||
}
|
||||
}
|
||||
|
||||
return &tcpproxy.DialProxy{
|
||||
Addr: net.JoinHostPort(sniName, port),
|
||||
DialContext: h.DialContext,
|
||||
}, true
|
||||
})
|
||||
p.Start()
|
||||
}
|
||||
159
appc/handlers_test.go
Normal file
159
appc/handlers_test.go
Normal file
@@ -0,0 +1,159 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package appc
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/hex"
|
||||
"io"
|
||||
"net"
|
||||
"net/netip"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"tailscale.com/net/memnet"
|
||||
)
|
||||
|
||||
func echoConnOnce(conn net.Conn) {
|
||||
defer conn.Close()
|
||||
|
||||
b := make([]byte, 256)
|
||||
n, err := conn.Read(b)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := conn.Write(b[:n]); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func TestTCPRoundRobinHandler(t *testing.T) {
|
||||
h := tcpRoundRobinHandler{
|
||||
To: []string{"yeet.com"},
|
||||
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
if network != "tcp" {
|
||||
t.Errorf("network = %s, want %s", network, "tcp")
|
||||
}
|
||||
if addr != "yeet.com:22" {
|
||||
t.Errorf("addr = %s, want %s", addr, "yeet.com:22")
|
||||
}
|
||||
|
||||
c, s := memnet.NewConn("outbound", 1024)
|
||||
go echoConnOnce(s)
|
||||
return c, nil
|
||||
},
|
||||
}
|
||||
|
||||
cSock, sSock := memnet.NewTCPConn(netip.MustParseAddrPort("10.64.1.2:22"), netip.MustParseAddrPort("10.64.1.2:22"), 1024)
|
||||
h.Handle(sSock)
|
||||
|
||||
// Test data write and read, the other end will echo back
|
||||
// a single stanza
|
||||
want := "hello"
|
||||
if _, err := io.WriteString(cSock, want); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := make([]byte, len(want))
|
||||
if _, err := io.ReadAtLeast(cSock, got, len(got)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(got) != want {
|
||||
t.Errorf("got %q, want %q", got, want)
|
||||
}
|
||||
|
||||
// The other end closed the socket after the first echo, so
|
||||
// any following read should error.
|
||||
io.WriteString(cSock, "deadass heres some data on god fr")
|
||||
if _, err := io.ReadAtLeast(cSock, got, len(got)); err == nil {
|
||||
t.Error("read succeeded on closed socket")
|
||||
}
|
||||
}
|
||||
|
||||
// Capture of first TCP data segment for a connection to https://pkgs.tailscale.com
|
||||
const tlsStart = `45000239ff1840004006f9f5c0a801f2
|
||||
c726b5efcf9e01bbe803b21394e3b752
|
||||
801801f641dc00000101080ade3474f2
|
||||
2fb93ee71603010200010001fc030303
|
||||
c3acbd19d2624765bb19af4bce03365e
|
||||
1d197f5bb939cdadeff26b0f8e7a0620
|
||||
295b04127b82bae46aac4ff58cffef25
|
||||
eba75a4b7a6de729532c411bd9dd0d2c
|
||||
00203a3a130113021303c02bc02fc02c
|
||||
c030cca9cca8c013c014009c009d002f
|
||||
003501000193caca0000000a000a0008
|
||||
1a1a001d001700180010000e000c0268
|
||||
3208687474702f312e31002b0007062a
|
||||
2a03040303ff01000100000d00120010
|
||||
04030804040105030805050108060601
|
||||
000b00020100002300000033002b0029
|
||||
1a1a000100001d0020d3c76bef062979
|
||||
a812ce935cfb4dbe6b3a84dc5ba9226f
|
||||
23b0f34af9d1d03b4a001b0003020002
|
||||
00120000446900050003026832000000
|
||||
170015000012706b67732e7461696c73
|
||||
63616c652e636f6d002d000201010005
|
||||
00050100000000001700003a3a000100
|
||||
0015002d000000000000000000000000
|
||||
00000000000000000000000000000000
|
||||
00000000000000000000000000000000
|
||||
0000290094006f0069e76f2016f963ad
|
||||
38c8632d1f240cd75e00e25fdef295d4
|
||||
7042b26f3a9a543b1c7dc74939d77803
|
||||
20527d423ff996997bda2c6383a14f49
|
||||
219eeef8a053e90a32228df37ddbe126
|
||||
eccf6b085c93890d08341d819aea6111
|
||||
0d909f4cd6b071d9ea40618e74588a33
|
||||
90d494bbb5c3002120d5a164a16c9724
|
||||
c9ef5e540d8d6f007789a7acf9f5f16f
|
||||
bf6a1907a6782ed02b`
|
||||
|
||||
func fakeSNIHeader() []byte {
|
||||
b, err := hex.DecodeString(strings.Replace(tlsStart, "\n", "", -1))
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return b[0x34:] // trim IP + TCP header
|
||||
}
|
||||
|
||||
func TestTCPSNIHandler(t *testing.T) {
|
||||
h := tcpSNIHandler{
|
||||
Allowlist: []string{"pkgs.tailscale.com"},
|
||||
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
if network != "tcp" {
|
||||
t.Errorf("network = %s, want %s", network, "tcp")
|
||||
}
|
||||
if addr != "pkgs.tailscale.com:443" {
|
||||
t.Errorf("addr = %s, want %s", addr, "pkgs.tailscale.com:443")
|
||||
}
|
||||
|
||||
c, s := memnet.NewConn("outbound", 1024)
|
||||
go echoConnOnce(s)
|
||||
return c, nil
|
||||
},
|
||||
}
|
||||
|
||||
cSock, sSock := memnet.NewTCPConn(netip.MustParseAddrPort("10.64.1.2:22"), netip.MustParseAddrPort("10.64.1.2:443"), 1024)
|
||||
h.Handle(sSock)
|
||||
|
||||
// Fake a TLS handshake record with an SNI in it.
|
||||
if _, err := cSock.Write(fakeSNIHeader()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Test read, the other end will echo back
|
||||
// a single stanza, which is at least the beginning of the SNI header.
|
||||
want := fakeSNIHeader()[:5]
|
||||
if _, err := cSock.Write(want); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := make([]byte, len(want))
|
||||
if _, err := io.ReadAtLeast(cSock, got, len(got)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !bytes.Equal(got, want) {
|
||||
t.Errorf("got %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
// Copyright (c) 2019 Tailscale Inc & AUTHORS All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style
|
||||
// license that can be found in the LICENSE file.
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
// Package atomicfile contains code related to writing to filesystems
|
||||
// atomically.
|
||||
@@ -9,16 +8,21 @@
|
||||
package atomicfile // import "tailscale.com/atomicfile"
|
||||
|
||||
import (
|
||||
"io/ioutil"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
)
|
||||
|
||||
// WriteFile writes data to filename+some suffix, then renames it
|
||||
// into filename.
|
||||
// WriteFile writes data to filename+some suffix, then renames it into filename.
|
||||
// The perm argument is ignored on Windows. If the target filename already
|
||||
// exists but is not a regular file, WriteFile returns an error.
|
||||
func WriteFile(filename string, data []byte, perm os.FileMode) (err error) {
|
||||
f, err := ioutil.TempFile(filepath.Dir(filename), filepath.Base(filename)+".tmp")
|
||||
fi, err := os.Stat(filename)
|
||||
if err == nil && !fi.Mode().IsRegular() {
|
||||
return fmt.Errorf("%s already exists and is not a regular file", filename)
|
||||
}
|
||||
f, err := os.CreateTemp(filepath.Dir(filename), filepath.Base(filename)+".tmp")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
47
atomicfile/atomicfile_test.go
Normal file
47
atomicfile/atomicfile_test.go
Normal file
@@ -0,0 +1,47 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
//go:build !js && !windows
|
||||
|
||||
package atomicfile
|
||||
|
||||
import (
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDoesNotOverwriteIrregularFiles(t *testing.T) {
|
||||
// Per tailscale/tailscale#7658 as one example, almost any imagined use of
|
||||
// atomicfile.Write should likely not attempt to overwrite an irregular file
|
||||
// such as a device node, socket, or named pipe.
|
||||
|
||||
const filename = "TestDoesNotOverwriteIrregularFiles"
|
||||
var path string
|
||||
// macOS private temp does not allow unix socket creation, but /tmp does.
|
||||
if runtime.GOOS == "darwin" {
|
||||
path = filepath.Join("/tmp", filename)
|
||||
t.Cleanup(func() { os.Remove(path) })
|
||||
} else {
|
||||
path = filepath.Join(t.TempDir(), filename)
|
||||
}
|
||||
|
||||
// The least troublesome thing to make that is not a file is a unix socket.
|
||||
// Making a null device sadly requires root.
|
||||
l, err := net.ListenUnix("unix", &net.UnixAddr{Name: path, Net: "unix"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer l.Close()
|
||||
|
||||
err = WriteFile(path, []byte("hello"), 0644)
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "is not a regular file") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -11,38 +11,42 @@
|
||||
|
||||
set -eu
|
||||
|
||||
IFS=".$IFS" read -r major minor patch <VERSION.txt
|
||||
git_hash=$(git rev-parse HEAD)
|
||||
if ! git diff-index --quiet HEAD; then
|
||||
git_hash="${git_hash}-dirty"
|
||||
fi
|
||||
base_hash=$(git rev-list --max-count=1 HEAD -- VERSION.txt)
|
||||
change_count=$(git rev-list --count HEAD "^$base_hash")
|
||||
short_hash=$(echo "$git_hash" | cut -c1-9)
|
||||
|
||||
if expr "$minor" : "[0-9]*[13579]$" >/dev/null; then
|
||||
patch="$change_count"
|
||||
change_suffix=""
|
||||
elif [ "$change_count" != "0" ]; then
|
||||
change_suffix="-$change_count"
|
||||
else
|
||||
change_suffix=""
|
||||
go="go"
|
||||
if [ -n "${TS_USE_TOOLCHAIN:-}" ]; then
|
||||
go="./tool/go"
|
||||
fi
|
||||
|
||||
long_suffix="$change_suffix-t$short_hash"
|
||||
MINOR="$major.$minor"
|
||||
SHORT="$MINOR.$patch"
|
||||
LONG="${SHORT}$long_suffix"
|
||||
GIT_HASH="$git_hash"
|
||||
eval `CGO_ENABLED=0 GOOS=$($go env GOHOSTOS) GOARCH=$($go env GOHOSTARCH) $go run ./cmd/mkversion`
|
||||
|
||||
if [ "$1" = "shellvars" ]; then
|
||||
cat <<EOF
|
||||
VERSION_MINOR="$MINOR"
|
||||
VERSION_SHORT="$SHORT"
|
||||
VERSION_LONG="$LONG"
|
||||
VERSION_GIT_HASH="$GIT_HASH"
|
||||
VERSION_MINOR="$VERSION_MINOR"
|
||||
VERSION_SHORT="$VERSION_SHORT"
|
||||
VERSION_LONG="$VERSION_LONG"
|
||||
VERSION_GIT_HASH="$VERSION_GIT_HASH"
|
||||
EOF
|
||||
exit 0
|
||||
fi
|
||||
|
||||
exec go build -ldflags "-X tailscale.com/version.Long=${LONG} -X tailscale.com/version.Short=${SHORT} -X tailscale.com/version.GitCommit=${GIT_HASH}" "$@"
|
||||
tags=""
|
||||
ldflags="-X tailscale.com/version.longStamp=${VERSION_LONG} -X tailscale.com/version.shortStamp=${VERSION_SHORT}"
|
||||
|
||||
# build_dist.sh arguments must precede go build arguments.
|
||||
while [ "$#" -gt 1 ]; do
|
||||
case "$1" in
|
||||
--extra-small)
|
||||
shift
|
||||
ldflags="$ldflags -w -s"
|
||||
tags="${tags:+$tags,}ts_omit_aws,ts_omit_bird,ts_omit_tap,ts_omit_kube"
|
||||
;;
|
||||
--box)
|
||||
shift
|
||||
tags="${tags:+$tags,}ts_include_cli"
|
||||
;;
|
||||
*)
|
||||
break
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
exec $go build ${tags:+-tags=$tags} -ldflags "$ldflags" "$@"
|
||||
|
||||
@@ -19,17 +19,56 @@
|
||||
|
||||
set -eu
|
||||
|
||||
# Use the "go" binary from the "tool" directory (which is github.com/tailscale/go)
|
||||
export PATH=$PWD/tool:$PATH
|
||||
|
||||
eval $(./build_dist.sh shellvars)
|
||||
|
||||
go run github.com/tailscale/mkctr@latest \
|
||||
--base="ghcr.io/tailscale/alpine-base:3.14" \
|
||||
--gopaths="\
|
||||
tailscale.com/cmd/tailscale:/usr/local/bin/tailscale, \
|
||||
tailscale.com/cmd/tailscaled:/usr/local/bin/tailscaled" \
|
||||
--ldflags="\
|
||||
-X tailscale.com/version.Long=${VERSION_LONG} \
|
||||
-X tailscale.com/version.Short=${VERSION_SHORT} \
|
||||
-X tailscale.com/version.GitCommit=${VERSION_GIT_HASH}" \
|
||||
--tags="v${VERSION_SHORT},v${VERSION_MINOR}" \
|
||||
--repos="tailscale/tailscale,ghcr.io/tailscale/tailscale" \
|
||||
--push
|
||||
DEFAULT_TARGET="client"
|
||||
DEFAULT_TAGS="v${VERSION_SHORT},v${VERSION_MINOR}"
|
||||
DEFAULT_BASE="tailscale/alpine-base:3.16"
|
||||
|
||||
PUSH="${PUSH:-false}"
|
||||
TARGET="${TARGET:-${DEFAULT_TARGET}}"
|
||||
TAGS="${TAGS:-${DEFAULT_TAGS}}"
|
||||
BASE="${BASE:-${DEFAULT_BASE}}"
|
||||
|
||||
case "$TARGET" in
|
||||
client)
|
||||
DEFAULT_REPOS="tailscale/tailscale"
|
||||
REPOS="${REPOS:-${DEFAULT_REPOS}}"
|
||||
go run github.com/tailscale/mkctr \
|
||||
--gopaths="\
|
||||
tailscale.com/cmd/tailscale:/usr/local/bin/tailscale, \
|
||||
tailscale.com/cmd/tailscaled:/usr/local/bin/tailscaled, \
|
||||
tailscale.com/cmd/containerboot:/usr/local/bin/containerboot" \
|
||||
--ldflags="\
|
||||
-X tailscale.com/version.longStamp=${VERSION_LONG} \
|
||||
-X tailscale.com/version.shortStamp=${VERSION_SHORT} \
|
||||
-X tailscale.com/version.gitCommitStamp=${VERSION_GIT_HASH}" \
|
||||
--base="${BASE}" \
|
||||
--tags="${TAGS}" \
|
||||
--repos="${REPOS}" \
|
||||
--push="${PUSH}" \
|
||||
/usr/local/bin/containerboot
|
||||
;;
|
||||
operator)
|
||||
DEFAULT_REPOS="tailscale/k8s-operator"
|
||||
REPOS="${REPOS:-${DEFAULT_REPOS}}"
|
||||
go run github.com/tailscale/mkctr \
|
||||
--gopaths="tailscale.com/cmd/k8s-operator:/usr/local/bin/operator" \
|
||||
--ldflags="\
|
||||
-X tailscale.com/version.longStamp=${VERSION_LONG} \
|
||||
-X tailscale.com/version.shortStamp=${VERSION_SHORT} \
|
||||
-X tailscale.com/version.gitCommitStamp=${VERSION_GIT_HASH}" \
|
||||
--base="${BASE}" \
|
||||
--tags="${TAGS}" \
|
||||
--repos="${REPOS}" \
|
||||
--push="${PUSH}" \
|
||||
/usr/local/bin/operator
|
||||
;;
|
||||
*)
|
||||
echo "unknown target: $TARGET"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
120
chirp/chirp.go
120
chirp/chirp.go
@@ -1,6 +1,5 @@
|
||||
// Copyright (c) 2021 Tailscale Inc & AUTHORS All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style
|
||||
// license that can be found in the LICENSE file.
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
// Package chirp implements a client to communicate with the BIRD Internet
|
||||
// Routing Daemon.
|
||||
@@ -11,17 +10,39 @@ import (
|
||||
"fmt"
|
||||
"net"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
// Maximum amount of time we should wait when reading a response from BIRD.
|
||||
responseTimeout = 10 * time.Second
|
||||
)
|
||||
|
||||
// New creates a BIRDClient.
|
||||
func New(socket string) (*BIRDClient, error) {
|
||||
return newWithTimeout(socket, responseTimeout)
|
||||
}
|
||||
|
||||
func newWithTimeout(socket string, timeout time.Duration) (_ *BIRDClient, err error) {
|
||||
conn, err := net.Dial("unix", socket)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to connect to BIRD: %w", err)
|
||||
}
|
||||
b := &BIRDClient{socket: socket, conn: conn, bs: bufio.NewScanner(conn)}
|
||||
defer func() {
|
||||
if err != nil {
|
||||
conn.Close()
|
||||
}
|
||||
}()
|
||||
|
||||
b := &BIRDClient{
|
||||
socket: socket,
|
||||
conn: conn,
|
||||
scanner: bufio.NewScanner(conn),
|
||||
timeNow: time.Now,
|
||||
timeout: timeout,
|
||||
}
|
||||
// Read and discard the first line as that is the welcome message.
|
||||
if _, err := b.readLine(); err != nil {
|
||||
if _, err := b.readResponse(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return b, nil
|
||||
@@ -29,9 +50,11 @@ func New(socket string) (*BIRDClient, error) {
|
||||
|
||||
// BIRDClient handles communication with the BIRD Internet Routing Daemon.
|
||||
type BIRDClient struct {
|
||||
socket string
|
||||
conn net.Conn
|
||||
bs *bufio.Scanner
|
||||
socket string
|
||||
conn net.Conn
|
||||
scanner *bufio.Scanner
|
||||
timeNow func() time.Time
|
||||
timeout time.Duration
|
||||
}
|
||||
|
||||
// Close closes the underlying connection to BIRD.
|
||||
@@ -39,7 +62,7 @@ func (b *BIRDClient) Close() error { return b.conn.Close() }
|
||||
|
||||
// DisableProtocol disables the provided protocol.
|
||||
func (b *BIRDClient) DisableProtocol(protocol string) error {
|
||||
out, err := b.exec("disable %s\n", protocol)
|
||||
out, err := b.exec("disable %s", protocol)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -53,7 +76,7 @@ func (b *BIRDClient) DisableProtocol(protocol string) error {
|
||||
|
||||
// EnableProtocol enables the provided protocol.
|
||||
func (b *BIRDClient) EnableProtocol(protocol string) error {
|
||||
out, err := b.exec("enable %s\n", protocol)
|
||||
out, err := b.exec("enable %s", protocol)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -65,19 +88,76 @@ func (b *BIRDClient) EnableProtocol(protocol string) error {
|
||||
return fmt.Errorf("failed to enable %s: %v", protocol, out)
|
||||
}
|
||||
|
||||
func (b *BIRDClient) exec(cmd string, args ...interface{}) (string, error) {
|
||||
// BIRD CLI docs from https://bird.network.cz/?get_doc&v=20&f=prog-2.html#ss2.9
|
||||
|
||||
// Each session of the CLI consists of a sequence of request and replies,
|
||||
// slightly resembling the FTP and SMTP protocols.
|
||||
// Requests are commands encoded as a single line of text,
|
||||
// replies are sequences of lines starting with a four-digit code
|
||||
// followed by either a space (if it's the last line of the reply) or
|
||||
// a minus sign (when the reply is going to continue with the next line),
|
||||
// the rest of the line contains a textual message semantics of which depends on the numeric code.
|
||||
// If a reply line has the same code as the previous one and it's a continuation line,
|
||||
// the whole prefix can be replaced by a single white space character.
|
||||
//
|
||||
// Reply codes starting with 0 stand for ‘action successfully completed’ messages,
|
||||
// 1 means ‘table entry’, 8 ‘runtime error’ and 9 ‘syntax error’.
|
||||
|
||||
func (b *BIRDClient) exec(cmd string, args ...any) (string, error) {
|
||||
if err := b.conn.SetWriteDeadline(b.timeNow().Add(b.timeout)); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if _, err := fmt.Fprintf(b.conn, cmd, args...); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return b.readLine()
|
||||
}
|
||||
|
||||
func (b *BIRDClient) readLine() (string, error) {
|
||||
if !b.bs.Scan() {
|
||||
return "", fmt.Errorf("reading response from bird failed")
|
||||
}
|
||||
if err := b.bs.Err(); err != nil {
|
||||
if _, err := fmt.Fprintln(b.conn); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return b.bs.Text(), nil
|
||||
return b.readResponse()
|
||||
}
|
||||
|
||||
// hasResponseCode reports whether the provided byte slice is
|
||||
// prefixed with a BIRD response code.
|
||||
// Equivalent regex: `^\d{4}[ -]`.
|
||||
func hasResponseCode(s []byte) bool {
|
||||
if len(s) < 5 {
|
||||
return false
|
||||
}
|
||||
for _, b := range s[:4] {
|
||||
if '0' <= b && b <= '9' {
|
||||
continue
|
||||
}
|
||||
return false
|
||||
}
|
||||
return s[4] == ' ' || s[4] == '-'
|
||||
}
|
||||
|
||||
func (b *BIRDClient) readResponse() (string, error) {
|
||||
// Set the read timeout before we start reading anything.
|
||||
if err := b.conn.SetReadDeadline(b.timeNow().Add(b.timeout)); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
var resp strings.Builder
|
||||
var done bool
|
||||
for !done {
|
||||
if !b.scanner.Scan() {
|
||||
if err := b.scanner.Err(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("reading response from bird failed (EOF): %q", resp.String())
|
||||
}
|
||||
out := b.scanner.Bytes()
|
||||
if _, err := resp.Write(out); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if hasResponseCode(out) {
|
||||
done = out[4] == ' '
|
||||
}
|
||||
if !done {
|
||||
resp.WriteRune('\n')
|
||||
}
|
||||
}
|
||||
return resp.String(), nil
|
||||
}
|
||||
|
||||
192
chirp/chirp_test.go
Normal file
192
chirp/chirp_test.go
Normal file
@@ -0,0 +1,192 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
package chirp
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
type fakeBIRD struct {
|
||||
net.Listener
|
||||
protocolsEnabled map[string]bool
|
||||
sock string
|
||||
}
|
||||
|
||||
func newFakeBIRD(t *testing.T, protocols ...string) *fakeBIRD {
|
||||
sock := filepath.Join(t.TempDir(), "sock")
|
||||
l, err := net.Listen("unix", sock)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pe := make(map[string]bool)
|
||||
for _, p := range protocols {
|
||||
pe[p] = false
|
||||
}
|
||||
return &fakeBIRD{
|
||||
Listener: l,
|
||||
protocolsEnabled: pe,
|
||||
sock: sock,
|
||||
}
|
||||
}
|
||||
|
||||
func (fb *fakeBIRD) listen() error {
|
||||
for {
|
||||
c, err := fb.Accept()
|
||||
if err != nil {
|
||||
if errors.Is(err, net.ErrClosed) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
go fb.handle(c)
|
||||
}
|
||||
}
|
||||
|
||||
func (fb *fakeBIRD) handle(c net.Conn) {
|
||||
fmt.Fprintln(c, "0001 BIRD 2.0.8 ready.")
|
||||
sc := bufio.NewScanner(c)
|
||||
for sc.Scan() {
|
||||
cmd := sc.Text()
|
||||
args := strings.Split(cmd, " ")
|
||||
switch args[0] {
|
||||
case "enable":
|
||||
en, ok := fb.protocolsEnabled[args[1]]
|
||||
if !ok {
|
||||
fmt.Fprintln(c, "9001 syntax error, unexpected CF_SYM_UNDEFINED, expecting CF_SYM_KNOWN or TEXT or ALL")
|
||||
} else if en {
|
||||
fmt.Fprintf(c, "0010-%s: already enabled\n", args[1])
|
||||
} else {
|
||||
fmt.Fprintf(c, "0011-%s: enabled\n", args[1])
|
||||
}
|
||||
fmt.Fprintln(c, "0000 ")
|
||||
fb.protocolsEnabled[args[1]] = true
|
||||
case "disable":
|
||||
en, ok := fb.protocolsEnabled[args[1]]
|
||||
if !ok {
|
||||
fmt.Fprintln(c, "9001 syntax error, unexpected CF_SYM_UNDEFINED, expecting CF_SYM_KNOWN or TEXT or ALL")
|
||||
} else if !en {
|
||||
fmt.Fprintf(c, "0008-%s: already disabled\n", args[1])
|
||||
} else {
|
||||
fmt.Fprintf(c, "0009-%s: disabled\n", args[1])
|
||||
}
|
||||
fmt.Fprintln(c, "0000 ")
|
||||
fb.protocolsEnabled[args[1]] = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestChirp(t *testing.T) {
|
||||
fb := newFakeBIRD(t, "tailscale")
|
||||
defer fb.Close()
|
||||
go fb.listen()
|
||||
c, err := New(fb.sock)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := c.EnableProtocol("tailscale"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := c.EnableProtocol("tailscale"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := c.DisableProtocol("tailscale"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := c.DisableProtocol("tailscale"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := c.EnableProtocol("rando"); err == nil {
|
||||
t.Fatalf("enabling %q succeeded", "rando")
|
||||
}
|
||||
if err := c.DisableProtocol("rando"); err == nil {
|
||||
t.Fatalf("disabling %q succeeded", "rando")
|
||||
}
|
||||
}
|
||||
|
||||
type hangingListener struct {
|
||||
net.Listener
|
||||
t *testing.T
|
||||
done chan struct{}
|
||||
wg sync.WaitGroup
|
||||
sock string
|
||||
}
|
||||
|
||||
func newHangingListener(t *testing.T) *hangingListener {
|
||||
sock := filepath.Join(t.TempDir(), "sock")
|
||||
l, err := net.Listen("unix", sock)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return &hangingListener{
|
||||
Listener: l,
|
||||
t: t,
|
||||
done: make(chan struct{}),
|
||||
sock: sock,
|
||||
}
|
||||
}
|
||||
|
||||
func (hl *hangingListener) Stop() {
|
||||
hl.Close()
|
||||
close(hl.done)
|
||||
hl.wg.Wait()
|
||||
}
|
||||
|
||||
func (hl *hangingListener) listen() error {
|
||||
for {
|
||||
c, err := hl.Accept()
|
||||
if err != nil {
|
||||
if errors.Is(err, net.ErrClosed) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
hl.wg.Add(1)
|
||||
go hl.handle(c)
|
||||
}
|
||||
}
|
||||
|
||||
func (hl *hangingListener) handle(c net.Conn) {
|
||||
defer hl.wg.Done()
|
||||
|
||||
// Write our fake first line of response so that we get into the read loop
|
||||
fmt.Fprintln(c, "0001 BIRD 2.0.8 ready.")
|
||||
|
||||
ticker := time.NewTicker(2 * time.Second)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
hl.t.Logf("connection still hanging")
|
||||
case <-hl.done:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestChirpTimeout(t *testing.T) {
|
||||
fb := newHangingListener(t)
|
||||
defer fb.Stop()
|
||||
go fb.listen()
|
||||
|
||||
c, err := newWithTimeout(fb.sock, 500*time.Millisecond)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
err = c.EnableProtocol("tailscale")
|
||||
if err == nil {
|
||||
t.Fatal("got err=nil, want timeout")
|
||||
}
|
||||
if !os.IsTimeout(err) {
|
||||
t.Fatalf("got err=%v, want os.IsTimeout(err)=true", err)
|
||||
}
|
||||
}
|
||||
476
client/tailscale/acl.go
Normal file
476
client/tailscale/acl.go
Normal file
@@ -0,0 +1,476 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
//go:build go1.19
|
||||
|
||||
package tailscale
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
)
|
||||
|
||||
// ACLRow defines a rule that grants access by a set of users or groups to a set
|
||||
// of servers and ports.
|
||||
// Only one of Src/Dst or Users/Ports may be specified.
|
||||
type ACLRow struct {
|
||||
Action string `json:"action,omitempty"` // valid values: "accept"
|
||||
Users []string `json:"users,omitempty"` // old name for src
|
||||
Ports []string `json:"ports,omitempty"` // old name for dst
|
||||
Src []string `json:"src,omitempty"`
|
||||
Dst []string `json:"dst,omitempty"`
|
||||
}
|
||||
|
||||
// ACLTest defines a test for your ACLs to prevent accidental exposure or
|
||||
// revoking of access to key servers and ports. Only one of Src or User may be
|
||||
// specified, and only one of Allow/Accept may be specified.
|
||||
type ACLTest struct {
|
||||
Src string `json:"src,omitempty"` // source
|
||||
User string `json:"user,omitempty"` // old name for source
|
||||
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
|
||||
}
|
||||
|
||||
// ACLDetails contains all the details for an ACL.
|
||||
type ACLDetails struct {
|
||||
Tests []ACLTest `json:"tests,omitempty"`
|
||||
ACLs []ACLRow `json:"acls,omitempty"`
|
||||
Groups map[string][]string `json:"groups,omitempty"`
|
||||
TagOwners map[string][]string `json:"tagowners,omitempty"`
|
||||
Hosts map[string]string `json:"hosts,omitempty"`
|
||||
}
|
||||
|
||||
// ACL contains an ACLDetails and metadata.
|
||||
type ACL struct {
|
||||
ACL ACLDetails
|
||||
ETag string // to check with version on server
|
||||
}
|
||||
|
||||
// ACLHuJSON contains the HuJSON string of the ACL and metadata.
|
||||
type ACLHuJSON struct {
|
||||
ACL string
|
||||
Warnings []string
|
||||
ETag string // to check with version on server
|
||||
}
|
||||
|
||||
// ACL makes a call to the Tailscale server to get a JSON-parsed version of the ACL.
|
||||
// The JSON-parsed version of the ACL contains no comments as proper JSON does not support
|
||||
// comments.
|
||||
func (c *Client) ACL(ctx context.Context) (acl *ACL, err error) {
|
||||
// Format return errors to be descriptive.
|
||||
defer func() {
|
||||
if err != nil {
|
||||
err = fmt.Errorf("tailscale.ACL: %w", err)
|
||||
}
|
||||
}()
|
||||
|
||||
path := fmt.Sprintf("%s/api/v2/tailnet/%s/acl", c.baseURL(), c.tailnet)
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", path, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
b, resp, err := c.sendRequest(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 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 {
|
||||
return nil, handleErrorResponse(b, resp)
|
||||
}
|
||||
|
||||
// Otherwise, try to decode the response.
|
||||
var aclDetails ACLDetails
|
||||
if err = json.Unmarshal(b, &aclDetails); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
acl = &ACL{
|
||||
ACL: aclDetails,
|
||||
ETag: resp.Header.Get("ETag"),
|
||||
}
|
||||
return acl, nil
|
||||
}
|
||||
|
||||
// ACLHuJSON makes a call to the Tailscale server to get the ACL HuJSON and returns
|
||||
// it as a string.
|
||||
// HuJSON is JSON with a few modifications to make it more human-friendly. The primary
|
||||
// changes are allowing comments and trailing comments. See the following links for more info:
|
||||
// https://tailscale.com/s/acl-format
|
||||
// https://github.com/tailscale/hujson
|
||||
func (c *Client) ACLHuJSON(ctx context.Context) (acl *ACLHuJSON, err error) {
|
||||
// Format return errors to be descriptive.
|
||||
defer func() {
|
||||
if err != nil {
|
||||
err = fmt.Errorf("tailscale.ACLHuJSON: %w", err)
|
||||
}
|
||||
}()
|
||||
|
||||
path := fmt.Sprintf("%s/api/v2/tailnet/%s/acl?details=1", c.baseURL(), c.tailnet)
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", path, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Accept", "application/hujson")
|
||||
b, resp, err := c.sendRequest(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, handleErrorResponse(b, resp)
|
||||
}
|
||||
|
||||
data := struct {
|
||||
ACL []byte `json:"acl"`
|
||||
Warnings []string `json:"warnings"`
|
||||
}{}
|
||||
if err := json.Unmarshal(b, &data); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
acl = &ACLHuJSON{
|
||||
ACL: string(data.ACL),
|
||||
Warnings: data.Warnings,
|
||||
ETag: resp.Header.Get("ETag"),
|
||||
}
|
||||
return acl, nil
|
||||
}
|
||||
|
||||
// ACLTestFailureSummary specifies a user for which ACL tests
|
||||
// failed and the related user-friendly error messages.
|
||||
//
|
||||
// ACLTestFailureSummary specifies the JSON format sent to the
|
||||
// JavaScript client to be rendered in the HTML.
|
||||
type ACLTestFailureSummary struct {
|
||||
User string `json:"user,omitempty"`
|
||||
Errors []string `json:"errors,omitempty"`
|
||||
Warnings []string `json:"warnings,omitempty"`
|
||||
}
|
||||
|
||||
// ACLTestError is ErrResponse but with an extra field to account for ACLTestFailureSummary.
|
||||
type ACLTestError struct {
|
||||
ErrResponse
|
||||
Data []ACLTestFailureSummary `json:"data"`
|
||||
}
|
||||
|
||||
func (e ACLTestError) Error() string {
|
||||
return fmt.Sprintf("%s, Data: %+v", e.ErrResponse.Error(), e.Data)
|
||||
}
|
||||
|
||||
func (c *Client) aclPOSTRequest(ctx context.Context, body []byte, avoidCollisions bool, etag, acceptHeader string) ([]byte, string, error) {
|
||||
path := fmt.Sprintf("%s/api/v2/tailnet/%s/acl", c.baseURL(), c.tailnet)
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", path, bytes.NewBuffer(body))
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
if avoidCollisions {
|
||||
req.Header.Set("If-Match", etag)
|
||||
}
|
||||
req.Header.Set("Accept", acceptHeader)
|
||||
req.Header.Set("Content-Type", "application/hujson")
|
||||
b, resp, err := c.sendRequest(req)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
// 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 {
|
||||
// check if test error
|
||||
var ate ACLTestError
|
||||
if err := json.Unmarshal(b, &ate); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
ate.Status = resp.StatusCode
|
||||
return nil, "", ate
|
||||
}
|
||||
return b, resp.Header.Get("ETag"), nil
|
||||
}
|
||||
|
||||
// SetACL sends a POST request to update the ACL according to the provided ACL object. If
|
||||
// `avoidCollisions` is true, it will use the ETag obtained in the GET request in an If-Match
|
||||
// header to check if the previously obtained ACL was the latest version and that no updates
|
||||
// were missed.
|
||||
//
|
||||
// Returns error with status code 412 if mistmached ETag and avoidCollisions is set to true.
|
||||
// Returns error if ACL has tests that fail.
|
||||
// Returns error if there are other errors with the ACL.
|
||||
func (c *Client) SetACL(ctx context.Context, acl ACL, avoidCollisions bool) (res *ACL, err error) {
|
||||
// Format return errors to be descriptive.
|
||||
defer func() {
|
||||
if err != nil {
|
||||
err = fmt.Errorf("tailscale.SetACL: %w", err)
|
||||
}
|
||||
}()
|
||||
postData, err := json.Marshal(acl.ACL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
b, etag, err := c.aclPOSTRequest(ctx, postData, avoidCollisions, acl.ETag, "application/json")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Otherwise, try to decode the response.
|
||||
var aclDetails ACLDetails
|
||||
if err = json.Unmarshal(b, &aclDetails); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
res = &ACL{
|
||||
ACL: aclDetails,
|
||||
ETag: etag,
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// SetACLHuJSON sends a POST request to update the ACL according to the provided ACL object. If
|
||||
// `avoidCollisions` is true, it will use the ETag obtained in the GET request in an If-Match
|
||||
// header to check if the previously obtained ACL was the latest version and that no updates
|
||||
// were missed.
|
||||
//
|
||||
// Returns error with status code 412 if mistmached ETag and avoidCollisions is set to true.
|
||||
// Returns error if the HuJSON is invalid.
|
||||
// Returns error if ACL has tests that fail.
|
||||
// Returns error if there are other errors with the ACL.
|
||||
func (c *Client) SetACLHuJSON(ctx context.Context, acl ACLHuJSON, avoidCollisions bool) (res *ACLHuJSON, err error) {
|
||||
// Format return errors to be descriptive.
|
||||
defer func() {
|
||||
if err != nil {
|
||||
err = fmt.Errorf("tailscale.SetACLHuJSON: %w", err)
|
||||
}
|
||||
}()
|
||||
|
||||
postData := []byte(acl.ACL)
|
||||
b, etag, err := c.aclPOSTRequest(ctx, postData, avoidCollisions, acl.ETag, "application/hujson")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
res = &ACLHuJSON{
|
||||
ACL: string(b),
|
||||
ETag: etag,
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// UserRuleMatch specifies the source users/groups/hosts that a rule targets
|
||||
// and the destination ports that they can access.
|
||||
// LineNumber is only useful for requests provided in HuJSON form.
|
||||
// While JSON requests will have LineNumber, the value is not useful.
|
||||
type UserRuleMatch struct {
|
||||
Users []string `json:"users"`
|
||||
Ports []string `json:"ports"`
|
||||
LineNumber int `json:"lineNumber"`
|
||||
}
|
||||
|
||||
// ACLPreviewResponse is the response type of previewACLPostRequest
|
||||
type ACLPreviewResponse struct {
|
||||
Matches []UserRuleMatch `json:"matches"` // ACL rules that match the specified user or ipport.
|
||||
Type string `json:"type"` // The request type: currently only "user" or "ipport".
|
||||
PreviewFor string `json:"previewFor"` // A specific user or ipport.
|
||||
}
|
||||
|
||||
// ACLPreview is the response type of PreviewACLForUser, PreviewACLForIPPort, PreviewACLHuJSONForUser, and PreviewACLHuJSONForIPPort
|
||||
type ACLPreview struct {
|
||||
Matches []UserRuleMatch `json:"matches"`
|
||||
User string `json:"user,omitempty"` // Filled if response of PreviewACLForUser or PreviewACLHuJSONForUser
|
||||
IPPort string `json:"ipport,omitempty"` // Filled if response of PreviewACLForIPPort or PreviewACLHuJSONForIPPort
|
||||
}
|
||||
|
||||
func (c *Client) previewACLPostRequest(ctx context.Context, body []byte, previewType string, previewFor string) (res *ACLPreviewResponse, err error) {
|
||||
path := fmt.Sprintf("%s/api/v2/tailnet/%s/acl/preview", c.baseURL(), c.tailnet)
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", path, bytes.NewBuffer(body))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
q := req.URL.Query()
|
||||
q.Add("type", previewType)
|
||||
q.Add("previewFor", previewFor)
|
||||
req.URL.RawQuery = q.Encode()
|
||||
|
||||
req.Header.Set("Content-Type", "application/hujson")
|
||||
c.setAuth(req)
|
||||
|
||||
b, resp, err := c.sendRequest(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 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 {
|
||||
return nil, handleErrorResponse(b, resp)
|
||||
}
|
||||
if err = json.Unmarshal(b, &res); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// PreviewACLForUser determines what rules match a given ACL for a user.
|
||||
// The ACL can be a locally modified or clean ACL obtained from server.
|
||||
//
|
||||
// Returns ACLPreview on success with matches in a slice. If there are no matches,
|
||||
// the call is still successful but Matches will be an empty slice.
|
||||
// Returns error if the provided ACL is invalid.
|
||||
func (c *Client) PreviewACLForUser(ctx context.Context, acl ACL, user string) (res *ACLPreview, err error) {
|
||||
// Format return errors to be descriptive.
|
||||
defer func() {
|
||||
if err != nil {
|
||||
err = fmt.Errorf("tailscale.PreviewACLForUser: %w", err)
|
||||
}
|
||||
}()
|
||||
postData, err := json.Marshal(acl.ACL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
b, err := c.previewACLPostRequest(ctx, postData, "user", user)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &ACLPreview{
|
||||
Matches: b.Matches,
|
||||
User: b.PreviewFor,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// PreviewACLForIPPort determines what rules match a given ACL for a ipport.
|
||||
// The ACL can be a locally modified or clean ACL obtained from server.
|
||||
//
|
||||
// Returns ACLPreview on success with matches in a slice. If there are no matches,
|
||||
// the call is still successful but Matches will be an empty slice.
|
||||
// Returns error if the provided ACL is invalid.
|
||||
func (c *Client) PreviewACLForIPPort(ctx context.Context, acl ACL, ipport netip.AddrPort) (res *ACLPreview, err error) {
|
||||
// Format return errors to be descriptive.
|
||||
defer func() {
|
||||
if err != nil {
|
||||
err = fmt.Errorf("tailscale.PreviewACLForIPPort: %w", err)
|
||||
}
|
||||
}()
|
||||
postData, err := json.Marshal(acl.ACL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
b, err := c.previewACLPostRequest(ctx, postData, "ipport", ipport.String())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &ACLPreview{
|
||||
Matches: b.Matches,
|
||||
IPPort: b.PreviewFor,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// PreviewACLHuJSONForUser determines what rules match a given ACL for a user.
|
||||
// The ACL can be a locally modified or clean ACL obtained from server.
|
||||
//
|
||||
// Returns ACLPreview on success with matches in a slice. If there are no matches,
|
||||
// the call is still successful but Matches will be an empty slice.
|
||||
// Returns error if the provided ACL is invalid.
|
||||
func (c *Client) PreviewACLHuJSONForUser(ctx context.Context, acl ACLHuJSON, user string) (res *ACLPreview, err error) {
|
||||
// Format return errors to be descriptive.
|
||||
defer func() {
|
||||
if err != nil {
|
||||
err = fmt.Errorf("tailscale.PreviewACLHuJSONForUser: %w", err)
|
||||
}
|
||||
}()
|
||||
postData := []byte(acl.ACL)
|
||||
b, err := c.previewACLPostRequest(ctx, postData, "user", user)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &ACLPreview{
|
||||
Matches: b.Matches,
|
||||
User: b.PreviewFor,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// PreviewACLHuJSONForIPPort determines what rules match a given ACL for a ipport.
|
||||
// The ACL can be a locally modified or clean ACL obtained from server.
|
||||
//
|
||||
// Returns ACLPreview on success with matches in a slice. If there are no matches,
|
||||
// the call is still successful but Matches will be an empty slice.
|
||||
// Returns error if the provided ACL is invalid.
|
||||
func (c *Client) PreviewACLHuJSONForIPPort(ctx context.Context, acl ACLHuJSON, ipport string) (res *ACLPreview, err error) {
|
||||
// Format return errors to be descriptive.
|
||||
defer func() {
|
||||
if err != nil {
|
||||
err = fmt.Errorf("tailscale.PreviewACLHuJSONForIPPort: %w", err)
|
||||
}
|
||||
}()
|
||||
postData := []byte(acl.ACL)
|
||||
b, err := c.previewACLPostRequest(ctx, postData, "ipport", ipport)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &ACLPreview{
|
||||
Matches: b.Matches,
|
||||
IPPort: b.PreviewFor,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ValidateACLJSON takes in the given source and destination (in this situation,
|
||||
// it is assumed that you are checking whether the source can connect to destination)
|
||||
// and creates an ACLTest from that. It then sends the ACLTest to the control api acl
|
||||
// validate endpoint, where the test is run. It returns a nil ACLTestError pointer if
|
||||
// no test errors occur.
|
||||
func (c *Client) ValidateACLJSON(ctx context.Context, source, dest string) (testErr *ACLTestError, err error) {
|
||||
// Format return errors to be descriptive.
|
||||
defer func() {
|
||||
if err != nil {
|
||||
err = fmt.Errorf("tailscale.ValidateACLJSON: %w", err)
|
||||
}
|
||||
}()
|
||||
|
||||
tests := []ACLTest{{User: source, Allow: []string{dest}}}
|
||||
postData, err := json.Marshal(tests)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
path := fmt.Sprintf("%s/api/v2/tailnet/%s/acl/validate", c.baseURL(), c.tailnet)
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", path, bytes.NewBuffer(postData))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
c.setAuth(req)
|
||||
|
||||
b, resp, err := c.sendRequest(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("control api responded with %d status code", resp.StatusCode)
|
||||
}
|
||||
|
||||
// The test ran without fail
|
||||
if len(b) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var res ACLTestError
|
||||
// The test returned errors.
|
||||
if err = json.Unmarshal(b, &res); err != nil {
|
||||
// failed to unmarshal
|
||||
return nil, err
|
||||
}
|
||||
return &res, nil
|
||||
}
|
||||
@@ -1,16 +1,23 @@
|
||||
// Copyright (c) 2021 Tailscale Inc & AUTHORS All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style
|
||||
// license that can be found in the LICENSE file.
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
// Package apitype contains types for the Tailscale local API.
|
||||
// Package apitype contains types for the Tailscale LocalAPI and control plane API.
|
||||
package apitype
|
||||
|
||||
import "tailscale.com/tailcfg"
|
||||
|
||||
// LocalAPIHost is the Host header value used by the LocalAPI.
|
||||
const LocalAPIHost = "local-tailscaled.sock"
|
||||
|
||||
// WhoIsResponse is the JSON type returned by tailscaled debug server's /whois?ip=$IP handler.
|
||||
// In successful whois responses, Node and UserProfile are never nil.
|
||||
type WhoIsResponse struct {
|
||||
Node *tailcfg.Node
|
||||
UserProfile *tailcfg.UserProfile
|
||||
|
||||
// CapMap is a map of capabilities to their values.
|
||||
// See tailcfg.PeerCapMap and tailcfg.PeerCapability for details.
|
||||
CapMap tailcfg.PeerCapMap
|
||||
}
|
||||
|
||||
// FileTarget is a node to which files can be sent, and the PeerAPI
|
||||
@@ -18,7 +25,7 @@ type WhoIsResponse struct {
|
||||
type FileTarget struct {
|
||||
Node *tailcfg.Node
|
||||
|
||||
// PeerAPI is the http://ip:port URL base of the node's peer API,
|
||||
// PeerAPI is the http://ip:port URL base of the node's PeerAPI,
|
||||
// without any path (not even a single slash).
|
||||
PeerAPIURL string
|
||||
}
|
||||
@@ -27,3 +34,18 @@ type WaitingFile struct {
|
||||
Name string
|
||||
Size int64
|
||||
}
|
||||
|
||||
// SetPushDeviceTokenRequest is the body POSTed to the LocalAPI endpoint /set-device-token.
|
||||
type SetPushDeviceTokenRequest struct {
|
||||
// PushDeviceToken is the iOS/macOS APNs device token (and any future Android equivalent).
|
||||
PushDeviceToken string
|
||||
}
|
||||
|
||||
// ReloadConfigResponse is the response to a LocalAPI reload-config request.
|
||||
//
|
||||
// There are three possible outcomes: (false, "") if no config mode in use,
|
||||
// (true, "") on success, or (false, "error message") on failure.
|
||||
type ReloadConfigResponse struct {
|
||||
Reloaded bool // whether the config was reloaded
|
||||
Err string // any error message
|
||||
}
|
||||
|
||||
19
client/tailscale/apitype/controltype.go
Normal file
19
client/tailscale/apitype/controltype.go
Normal file
@@ -0,0 +1,19 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package apitype
|
||||
|
||||
type DNSConfig struct {
|
||||
Resolvers []DNSResolver `json:"resolvers"`
|
||||
FallbackResolvers []DNSResolver `json:"fallbackResolvers"`
|
||||
Routes map[string][]DNSResolver `json:"routes"`
|
||||
Domains []string `json:"domains"`
|
||||
Nameservers []string `json:"nameservers"`
|
||||
Proxied bool `json:"proxied"`
|
||||
TempCorpIssue13969 string `json:"TempCorpIssue13969,omitempty"`
|
||||
}
|
||||
|
||||
type DNSResolver struct {
|
||||
Addr string `json:"addr"`
|
||||
BootstrapResolution []string `json:"bootstrapResolution,omitempty"`
|
||||
}
|
||||
272
client/tailscale/devices.go
Normal file
272
client/tailscale/devices.go
Normal file
@@ -0,0 +1,272 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
//go:build go1.19
|
||||
|
||||
package tailscale
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
"tailscale.com/types/opt"
|
||||
)
|
||||
|
||||
type GetDevicesResponse struct {
|
||||
Devices []*Device `json:"devices"`
|
||||
}
|
||||
|
||||
type DerpRegion struct {
|
||||
Preferred bool `json:"preferred,omitempty"`
|
||||
LatencyMilliseconds float64 `json:"latencyMs"`
|
||||
}
|
||||
|
||||
type ClientConnectivity struct {
|
||||
Endpoints []string `json:"endpoints"`
|
||||
DERP string `json:"derp"`
|
||||
MappingVariesByDestIP opt.Bool `json:"mappingVariesByDestIP"`
|
||||
// DERPLatency is mapped by region name (e.g. "New York City", "Seattle").
|
||||
DERPLatency map[string]DerpRegion `json:"latency"`
|
||||
ClientSupports map[string]opt.Bool `json:"clientSupports"`
|
||||
}
|
||||
|
||||
type Device struct {
|
||||
// Addresses is a list of the devices's Tailscale IP addresses.
|
||||
// It's currently just 1 element, the 100.x.y.z Tailscale IP.
|
||||
Addresses []string `json:"addresses"`
|
||||
DeviceID string `json:"id"`
|
||||
User string `json:"user"`
|
||||
Name string `json:"name"`
|
||||
Hostname string `json:"hostname"`
|
||||
|
||||
ClientVersion string `json:"clientVersion"` // Empty for external devices.
|
||||
UpdateAvailable bool `json:"updateAvailable"` // Empty for external devices.
|
||||
OS string `json:"os"`
|
||||
Tags []string `json:"tags"`
|
||||
Created string `json:"created"` // Empty for external devices.
|
||||
LastSeen string `json:"lastSeen"`
|
||||
KeyExpiryDisabled bool `json:"keyExpiryDisabled"`
|
||||
Expires string `json:"expires"`
|
||||
Authorized bool `json:"authorized"`
|
||||
IsExternal bool `json:"isExternal"`
|
||||
MachineKey string `json:"machineKey"` // Empty for external devices.
|
||||
NodeKey string `json:"nodeKey"`
|
||||
|
||||
// BlocksIncomingConnections is configured via the device's
|
||||
// Tailscale client preferences. This field is only reported
|
||||
// to the API starting with Tailscale 1.3.x clients.
|
||||
BlocksIncomingConnections bool `json:"blocksIncomingConnections"`
|
||||
|
||||
// The following fields are not included by default:
|
||||
|
||||
// EnabledRoutes are the previously-approved subnet routes
|
||||
// (e.g. "192.168.4.16/24", "10.5.2.4/32").
|
||||
EnabledRoutes []string `json:"enabledRoutes"` // Empty for external devices.
|
||||
// AdvertisedRoutes are the subnets (both enabled and not enabled)
|
||||
// being requested from the node.
|
||||
AdvertisedRoutes []string `json:"advertisedRoutes"` // Empty for external devices.
|
||||
|
||||
ClientConnectivity *ClientConnectivity `json:"clientConnectivity"`
|
||||
}
|
||||
|
||||
// DeviceFieldsOpts determines which fields should be returned in the response.
|
||||
//
|
||||
// Please only use DeviceAllFields and DeviceDefaultFields.
|
||||
// Other DeviceFieldsOpts are not supported.
|
||||
//
|
||||
// TODO: Support other DeviceFieldsOpts.
|
||||
// In the future, users should be able to create their own DeviceFieldsOpts
|
||||
// as valid arguments by setting the fields they want returned to a "non-nil"
|
||||
// value. For example, DeviceFieldsOpts{NodeID: "true"} should only return NodeIDs.
|
||||
type DeviceFieldsOpts Device
|
||||
|
||||
func (d *DeviceFieldsOpts) addFieldsToQueryParameter() string {
|
||||
if d == DeviceDefaultFields || d == nil {
|
||||
return "default"
|
||||
}
|
||||
if d == DeviceAllFields {
|
||||
return "all"
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
var (
|
||||
DeviceAllFields = &DeviceFieldsOpts{}
|
||||
|
||||
// DeviceDefaultFields specifies that the following fields are returned:
|
||||
// Addresses, NodeID, User, Name, Hostname, ClientVersion, UpdateAvailable,
|
||||
// OS, Created, LastSeen, KeyExpiryDisabled, Expires, Authorized, IsExternal
|
||||
// MachineKey, NodeKey, BlocksIncomingConnections.
|
||||
DeviceDefaultFields = &DeviceFieldsOpts{}
|
||||
)
|
||||
|
||||
// Devices retrieves the list of devices for a tailnet.
|
||||
//
|
||||
// See the Device structure for the list of fields hidden for external devices.
|
||||
// The optional fields parameter specifies which fields of the devices to return; currently
|
||||
// only DeviceDefaultFields (equivalent to nil) and DeviceAllFields are supported.
|
||||
// Other values are currently undefined.
|
||||
func (c *Client) Devices(ctx context.Context, fields *DeviceFieldsOpts) (deviceList []*Device, err error) {
|
||||
defer func() {
|
||||
if err != nil {
|
||||
err = fmt.Errorf("tailscale.Devices: %w", err)
|
||||
}
|
||||
}()
|
||||
|
||||
path := fmt.Sprintf("%s/api/v2/tailnet/%s/devices", c.baseURL(), c.tailnet)
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", path, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Add fields.
|
||||
fieldStr := fields.addFieldsToQueryParameter()
|
||||
q := req.URL.Query()
|
||||
q.Add("fields", fieldStr)
|
||||
req.URL.RawQuery = q.Encode()
|
||||
|
||||
b, resp, err := c.sendRequest(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 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 {
|
||||
return nil, handleErrorResponse(b, resp)
|
||||
}
|
||||
|
||||
var devices GetDevicesResponse
|
||||
err = json.Unmarshal(b, &devices)
|
||||
return devices.Devices, err
|
||||
}
|
||||
|
||||
// Device retrieved the details for a specific device.
|
||||
//
|
||||
// See the Device structure for the list of fields hidden for an external device.
|
||||
// The optional fields parameter specifies which fields of the devices to return; currently
|
||||
// only DeviceDefaultFields (equivalent to nil) and DeviceAllFields are supported.
|
||||
// Other values are currently undefined.
|
||||
func (c *Client) Device(ctx context.Context, deviceID string, fields *DeviceFieldsOpts) (device *Device, err error) {
|
||||
defer func() {
|
||||
if err != nil {
|
||||
err = fmt.Errorf("tailscale.Device: %w", err)
|
||||
}
|
||||
}()
|
||||
path := fmt.Sprintf("%s/api/v2/device/%s", c.baseURL(), deviceID)
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", path, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Add fields.
|
||||
fieldStr := fields.addFieldsToQueryParameter()
|
||||
q := req.URL.Query()
|
||||
q.Add("fields", fieldStr)
|
||||
req.URL.RawQuery = q.Encode()
|
||||
|
||||
b, resp, err := c.sendRequest(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 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 {
|
||||
return nil, handleErrorResponse(b, resp)
|
||||
}
|
||||
|
||||
err = json.Unmarshal(b, &device)
|
||||
return device, err
|
||||
}
|
||||
|
||||
// DeleteDevice deletes the specified device from the Client's tailnet.
|
||||
// NOTE: Only devices that belong to the Client's tailnet can be deleted.
|
||||
// Deleting external devices is not supported.
|
||||
func (c *Client) DeleteDevice(ctx context.Context, deviceID string) (err error) {
|
||||
defer func() {
|
||||
if err != nil {
|
||||
err = fmt.Errorf("tailscale.DeleteDevice: %w", err)
|
||||
}
|
||||
}()
|
||||
|
||||
path := fmt.Sprintf("%s/api/v2/device/%s", c.baseURL(), url.PathEscape(deviceID))
|
||||
req, err := http.NewRequestWithContext(ctx, "DELETE", path, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
b, resp, err := c.sendRequest(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// 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 {
|
||||
return handleErrorResponse(b, resp)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// AuthorizeDevice marks a device as authorized.
|
||||
func (c *Client) AuthorizeDevice(ctx context.Context, deviceID string) error {
|
||||
return c.SetAuthorized(ctx, deviceID, true)
|
||||
}
|
||||
|
||||
// SetAuthorized marks a device as authorized or not.
|
||||
func (c *Client) SetAuthorized(ctx context.Context, deviceID string, authorized bool) error {
|
||||
params := &struct {
|
||||
Authorized bool `json:"authorized"`
|
||||
}{Authorized: authorized}
|
||||
data, err := json.Marshal(params)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
path := fmt.Sprintf("%s/api/v2/device/%s/authorized", c.baseURL(), url.PathEscape(deviceID))
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", path, bytes.NewBuffer(data))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
b, resp, err := c.sendRequest(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// 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 {
|
||||
return handleErrorResponse(b, resp)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetTags updates the ACL tags on a device.
|
||||
func (c *Client) SetTags(ctx context.Context, deviceID string, tags []string) error {
|
||||
params := &struct {
|
||||
Tags []string `json:"tags"`
|
||||
}{Tags: tags}
|
||||
data, err := json.Marshal(params)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
path := fmt.Sprintf("%s/api/v2/device/%s/tags", c.baseURL(), url.PathEscape(deviceID))
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", path, bytes.NewBuffer(data))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
b, resp, err := c.sendRequest(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// 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 {
|
||||
return handleErrorResponse(b, resp)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
233
client/tailscale/dns.go
Normal file
233
client/tailscale/dns.go
Normal file
@@ -0,0 +1,233 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
//go:build go1.19
|
||||
|
||||
package tailscale
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"tailscale.com/client/tailscale/apitype"
|
||||
)
|
||||
|
||||
// DNSNameServers is returned when retrieving the list of nameservers.
|
||||
// It is also the structure provided when setting nameservers.
|
||||
type DNSNameServers struct {
|
||||
DNS []string `json:"dns"` // DNS name servers
|
||||
}
|
||||
|
||||
// DNSNameServersPostResponse is returned when setting the list of DNS nameservers.
|
||||
//
|
||||
// It includes the MagicDNS status since nameservers changes may affect MagicDNS.
|
||||
type DNSNameServersPostResponse struct {
|
||||
DNS []string `json:"dns"` // DNS name servers
|
||||
MagicDNS bool `json:"magicDNS"` // whether MagicDNS is active for this tailnet (enabled + has fallback nameservers)
|
||||
}
|
||||
|
||||
// DNSSearchpaths is the list of search paths for a given domain.
|
||||
type DNSSearchPaths struct {
|
||||
SearchPaths []string `json:"searchPaths"` // DNS search paths
|
||||
}
|
||||
|
||||
// DNSPreferences is the preferences set for a given tailnet.
|
||||
//
|
||||
// It includes MagicDNS which can be turned on or off. To enable MagicDNS,
|
||||
// there must be at least one nameserver. When all nameservers are removed,
|
||||
// MagicDNS is disabled.
|
||||
type DNSPreferences struct {
|
||||
MagicDNS bool `json:"magicDNS"` // whether MagicDNS is active for this tailnet (enabled + has fallback nameservers)
|
||||
}
|
||||
|
||||
func (c *Client) dnsGETRequest(ctx context.Context, endpoint string) ([]byte, error) {
|
||||
path := fmt.Sprintf("%s/api/v2/tailnet/%s/dns/%s", c.baseURL(), c.tailnet, endpoint)
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", path, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
b, resp, err := c.sendRequest(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 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 {
|
||||
return nil, handleErrorResponse(b, resp)
|
||||
}
|
||||
|
||||
return b, nil
|
||||
}
|
||||
|
||||
func (c *Client) dnsPOSTRequest(ctx context.Context, endpoint string, postData any) ([]byte, error) {
|
||||
path := fmt.Sprintf("%s/api/v2/tailnet/%s/dns/%s", c.baseURL(), c.tailnet, endpoint)
|
||||
data, err := json.Marshal(&postData)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", path, bytes.NewBuffer(data))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
b, resp, err := c.sendRequest(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 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 {
|
||||
return nil, handleErrorResponse(b, resp)
|
||||
}
|
||||
|
||||
return b, nil
|
||||
}
|
||||
|
||||
// DNSConfig retrieves the DNSConfig settings for a domain.
|
||||
func (c *Client) DNSConfig(ctx context.Context) (cfg *apitype.DNSConfig, err error) {
|
||||
// Format return errors to be descriptive.
|
||||
defer func() {
|
||||
if err != nil {
|
||||
err = fmt.Errorf("tailscale.DNSConfig: %w", err)
|
||||
}
|
||||
}()
|
||||
b, err := c.dnsGETRequest(ctx, "config")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var dnsResp apitype.DNSConfig
|
||||
err = json.Unmarshal(b, &dnsResp)
|
||||
return &dnsResp, err
|
||||
}
|
||||
|
||||
func (c *Client) SetDNSConfig(ctx context.Context, cfg apitype.DNSConfig) (resp *apitype.DNSConfig, err error) {
|
||||
// Format return errors to be descriptive.
|
||||
defer func() {
|
||||
if err != nil {
|
||||
err = fmt.Errorf("tailscale.SetDNSConfig: %w", err)
|
||||
}
|
||||
}()
|
||||
var dnsResp apitype.DNSConfig
|
||||
b, err := c.dnsPOSTRequest(ctx, "config", cfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
err = json.Unmarshal(b, &dnsResp)
|
||||
return &dnsResp, err
|
||||
}
|
||||
|
||||
// NameServers retrieves the list of nameservers set for a domain.
|
||||
func (c *Client) NameServers(ctx context.Context) (nameservers []string, err error) {
|
||||
// Format return errors to be descriptive.
|
||||
defer func() {
|
||||
if err != nil {
|
||||
err = fmt.Errorf("tailscale.NameServers: %w", err)
|
||||
}
|
||||
}()
|
||||
b, err := c.dnsGETRequest(ctx, "nameservers")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var dnsResp DNSNameServers
|
||||
err = json.Unmarshal(b, &dnsResp)
|
||||
return dnsResp.DNS, err
|
||||
}
|
||||
|
||||
// SetNameServers sets the list of nameservers for a tailnet to the list provided
|
||||
// by the user.
|
||||
//
|
||||
// It returns the new list of nameservers and the MagicDNS status in case it was
|
||||
// affected by the change. For example, removing all nameservers will turn off
|
||||
// MagicDNS.
|
||||
func (c *Client) SetNameServers(ctx context.Context, nameservers []string) (dnsResp *DNSNameServersPostResponse, err error) {
|
||||
defer func() {
|
||||
if err != nil {
|
||||
err = fmt.Errorf("tailscale.SetNameServers: %w", err)
|
||||
}
|
||||
}()
|
||||
dnsReq := DNSNameServers{DNS: nameservers}
|
||||
b, err := c.dnsPOSTRequest(ctx, "nameservers", dnsReq)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
err = json.Unmarshal(b, &dnsResp)
|
||||
return dnsResp, err
|
||||
}
|
||||
|
||||
// DNSPreferences retrieves the DNS preferences set for a tailnet.
|
||||
//
|
||||
// It returns the status of MagicDNS.
|
||||
func (c *Client) DNSPreferences(ctx context.Context) (dnsResp *DNSPreferences, err error) {
|
||||
// Format return errors to be descriptive.
|
||||
defer func() {
|
||||
if err != nil {
|
||||
err = fmt.Errorf("tailscale.DNSPreferences: %w", err)
|
||||
}
|
||||
}()
|
||||
b, err := c.dnsGETRequest(ctx, "preferences")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
err = json.Unmarshal(b, &dnsResp)
|
||||
return dnsResp, err
|
||||
}
|
||||
|
||||
// SetDNSPreferences sets the DNS preferences for a tailnet.
|
||||
//
|
||||
// MagicDNS can only be enabled when there is at least one nameserver provided.
|
||||
// When all nameservers are removed, MagicDNS is disabled and will stay disabled,
|
||||
// unless explicitly enabled by a user again.
|
||||
func (c *Client) SetDNSPreferences(ctx context.Context, magicDNS bool) (dnsResp *DNSPreferences, err error) {
|
||||
defer func() {
|
||||
if err != nil {
|
||||
err = fmt.Errorf("tailscale.SetDNSPreferences: %w", err)
|
||||
}
|
||||
}()
|
||||
dnsReq := DNSPreferences{MagicDNS: magicDNS}
|
||||
b, err := c.dnsPOSTRequest(ctx, "preferences", dnsReq)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
err = json.Unmarshal(b, &dnsResp)
|
||||
return dnsResp, err
|
||||
}
|
||||
|
||||
// SearchPaths retrieves the list of searchpaths set for a tailnet.
|
||||
func (c *Client) SearchPaths(ctx context.Context) (searchpaths []string, err error) {
|
||||
defer func() {
|
||||
if err != nil {
|
||||
err = fmt.Errorf("tailscale.SearchPaths: %w", err)
|
||||
}
|
||||
}()
|
||||
b, err := c.dnsGETRequest(ctx, "searchpaths")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var dnsResp *DNSSearchPaths
|
||||
err = json.Unmarshal(b, &dnsResp)
|
||||
return dnsResp.SearchPaths, err
|
||||
}
|
||||
|
||||
// SetSearchPaths sets the list of searchpaths for a tailnet.
|
||||
func (c *Client) SetSearchPaths(ctx context.Context, searchpaths []string) (newSearchPaths []string, err error) {
|
||||
defer func() {
|
||||
if err != nil {
|
||||
err = fmt.Errorf("tailscale.SetSearchPaths: %w", err)
|
||||
}
|
||||
}()
|
||||
dnsReq := DNSSearchPaths{SearchPaths: searchpaths}
|
||||
b, err := c.dnsPOSTRequest(ctx, "searchpaths", dnsReq)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var dnsResp DNSSearchPaths
|
||||
err = json.Unmarshal(b, &dnsResp)
|
||||
return dnsResp.SearchPaths, err
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
// Copyright (c) 2021 Tailscale Inc & AUTHORS All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style
|
||||
// license that can be found in the LICENSE file.
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
// The servetls program shows how to run an HTTPS server
|
||||
// using a Tailscale cert via LetsEncrypt.
|
||||
|
||||
166
client/tailscale/keys.go
Normal file
166
client/tailscale/keys.go
Normal file
@@ -0,0 +1,166 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package tailscale
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Key represents a Tailscale API or auth key.
|
||||
type Key struct {
|
||||
ID string `json:"id"`
|
||||
Created time.Time `json:"created"`
|
||||
Expires time.Time `json:"expires"`
|
||||
Capabilities KeyCapabilities `json:"capabilities"`
|
||||
}
|
||||
|
||||
// KeyCapabilities are the capabilities of a Key.
|
||||
type KeyCapabilities struct {
|
||||
Devices KeyDeviceCapabilities `json:"devices,omitempty"`
|
||||
}
|
||||
|
||||
// KeyDeviceCapabilities are the device-related capabilities of a Key.
|
||||
type KeyDeviceCapabilities struct {
|
||||
Create KeyDeviceCreateCapabilities `json:"create"`
|
||||
}
|
||||
|
||||
// KeyDeviceCreateCapabilities are the device creation capabilities of a Key.
|
||||
type KeyDeviceCreateCapabilities struct {
|
||||
Reusable bool `json:"reusable"`
|
||||
Ephemeral bool `json:"ephemeral"`
|
||||
Preauthorized bool `json:"preauthorized"`
|
||||
Tags []string `json:"tags,omitempty"`
|
||||
}
|
||||
|
||||
// Keys returns the list of keys for the current user.
|
||||
func (c *Client) Keys(ctx context.Context) ([]string, error) {
|
||||
path := fmt.Sprintf("%s/api/v2/tailnet/%s/keys", c.baseURL(), c.tailnet)
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", path, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
b, resp, err := c.sendRequest(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, handleErrorResponse(b, resp)
|
||||
}
|
||||
|
||||
var keys struct {
|
||||
Keys []*Key `json:"keys"`
|
||||
}
|
||||
if err := json.Unmarshal(b, &keys); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ret := make([]string, 0, len(keys.Keys))
|
||||
for _, k := range keys.Keys {
|
||||
ret = append(ret, k.ID)
|
||||
}
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
// CreateKey creates a new key for the current user. Currently, only auth keys
|
||||
// can be created. It returns the secret key itself, which cannot be retrieved again
|
||||
// later, and the key metadata.
|
||||
//
|
||||
// To create a key with a specific expiry, use CreateKeyWithExpiry.
|
||||
func (c *Client) CreateKey(ctx context.Context, caps KeyCapabilities) (keySecret string, keyMeta *Key, _ error) {
|
||||
return c.CreateKeyWithExpiry(ctx, caps, 0)
|
||||
}
|
||||
|
||||
// CreateKeyWithExpiry is like CreateKey, but allows specifying a expiration time.
|
||||
//
|
||||
// The time is truncated to a whole number of seconds. If zero, that means no expiration.
|
||||
func (c *Client) CreateKeyWithExpiry(ctx context.Context, caps KeyCapabilities, expiry time.Duration) (keySecret string, keyMeta *Key, _ error) {
|
||||
|
||||
// convert expirySeconds to an int64 (seconds)
|
||||
expirySeconds := int64(expiry.Seconds())
|
||||
if expirySeconds < 0 {
|
||||
return "", nil, fmt.Errorf("expiry must be positive")
|
||||
}
|
||||
if expirySeconds == 0 && expiry != 0 {
|
||||
return "", nil, fmt.Errorf("non-zero expiry must be at least one second")
|
||||
}
|
||||
|
||||
keyRequest := struct {
|
||||
Capabilities KeyCapabilities `json:"capabilities"`
|
||||
ExpirySeconds int64 `json:"expirySeconds,omitempty"`
|
||||
}{caps, int64(expirySeconds)}
|
||||
bs, err := json.Marshal(keyRequest)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
|
||||
path := fmt.Sprintf("%s/api/v2/tailnet/%s/keys", c.baseURL(), c.tailnet)
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", path, bytes.NewReader(bs))
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
|
||||
b, resp, err := c.sendRequest(req)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", nil, handleErrorResponse(b, resp)
|
||||
}
|
||||
|
||||
var key struct {
|
||||
Key
|
||||
Secret string `json:"key"`
|
||||
}
|
||||
if err := json.Unmarshal(b, &key); err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
return key.Secret, &key.Key, nil
|
||||
}
|
||||
|
||||
// Key returns the metadata for the given key ID. Currently, capabilities are
|
||||
// only returned for auth keys, API keys only return general metadata.
|
||||
func (c *Client) Key(ctx context.Context, id string) (*Key, error) {
|
||||
path := fmt.Sprintf("%s/api/v2/tailnet/%s/keys/%s", c.baseURL(), c.tailnet, id)
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", path, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
b, resp, err := c.sendRequest(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, handleErrorResponse(b, resp)
|
||||
}
|
||||
|
||||
var key Key
|
||||
if err := json.Unmarshal(b, &key); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &key, nil
|
||||
}
|
||||
|
||||
// DeleteKey deletes the key with the given ID.
|
||||
func (c *Client) DeleteKey(ctx context.Context, id string) error {
|
||||
path := fmt.Sprintf("%s/api/v2/tailnet/%s/keys/%s", c.baseURL(), c.tailnet, id)
|
||||
req, err := http.NewRequestWithContext(ctx, "DELETE", path, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
b, resp, err := c.sendRequest(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return handleErrorResponse(b, resp)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
1415
client/tailscale/localclient.go
Normal file
1415
client/tailscale/localclient.go
Normal file
File diff suppressed because it is too large
Load Diff
27
client/tailscale/localclient_test.go
Normal file
27
client/tailscale/localclient_test.go
Normal file
@@ -0,0 +1,27 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
//go:build go1.19
|
||||
|
||||
package tailscale
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestGetServeConfigFromJSON(t *testing.T) {
|
||||
sc, err := getServeConfigFromJSON([]byte("null"))
|
||||
if sc != nil {
|
||||
t.Errorf("want nil for null")
|
||||
}
|
||||
if err != nil {
|
||||
t.Errorf("reading null: %v", err)
|
||||
}
|
||||
|
||||
sc, err = getServeConfigFromJSON([]byte(`{"TCP":{}}`))
|
||||
if err != nil {
|
||||
t.Errorf("reading object: %v", err)
|
||||
} else if sc == nil {
|
||||
t.Errorf("want non-nil for object")
|
||||
} else if sc.TCP == nil {
|
||||
t.Errorf("want non-nil TCP for object")
|
||||
}
|
||||
}
|
||||
10
client/tailscale/required_version.go
Normal file
10
client/tailscale/required_version.go
Normal file
@@ -0,0 +1,10 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
//go:build !go1.21
|
||||
|
||||
package tailscale
|
||||
|
||||
func init() {
|
||||
you_need_Go_1_21_to_compile_Tailscale()
|
||||
}
|
||||
95
client/tailscale/routes.go
Normal file
95
client/tailscale/routes.go
Normal file
@@ -0,0 +1,95 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
//go:build go1.19
|
||||
|
||||
package tailscale
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
)
|
||||
|
||||
// Routes contains the lists of subnet routes that are currently advertised by a device,
|
||||
// as well as the subnets that are enabled to be routed by the device.
|
||||
type Routes struct {
|
||||
AdvertisedRoutes []netip.Prefix `json:"advertisedRoutes"`
|
||||
EnabledRoutes []netip.Prefix `json:"enabledRoutes"`
|
||||
}
|
||||
|
||||
// Routes retrieves the list of subnet routes that have been enabled for a device.
|
||||
// The routes that are returned are not necessarily advertised by the device,
|
||||
// they have only been preapproved.
|
||||
func (c *Client) Routes(ctx context.Context, deviceID string) (routes *Routes, err error) {
|
||||
defer func() {
|
||||
if err != nil {
|
||||
err = fmt.Errorf("tailscale.Routes: %w", err)
|
||||
}
|
||||
}()
|
||||
|
||||
path := fmt.Sprintf("%s/api/v2/device/%s/routes", c.baseURL(), deviceID)
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", path, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
b, resp, err := c.sendRequest(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 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 {
|
||||
return nil, handleErrorResponse(b, resp)
|
||||
}
|
||||
|
||||
var sr Routes
|
||||
err = json.Unmarshal(b, &sr)
|
||||
return &sr, err
|
||||
}
|
||||
|
||||
type postRoutesParams struct {
|
||||
Routes []netip.Prefix `json:"routes"`
|
||||
}
|
||||
|
||||
// SetRoutes updates the list of subnets that are enabled for a device.
|
||||
// Subnets must be parsable by net/netip.ParsePrefix.
|
||||
// Subnets do not have to be currently advertised by a device, they may be pre-enabled.
|
||||
// Returns the updated list of enabled and advertised subnet routes in a *Routes object.
|
||||
func (c *Client) SetRoutes(ctx context.Context, deviceID string, subnets []netip.Prefix) (routes *Routes, err error) {
|
||||
defer func() {
|
||||
if err != nil {
|
||||
err = fmt.Errorf("tailscale.SetRoutes: %w", err)
|
||||
}
|
||||
}()
|
||||
params := &postRoutesParams{Routes: subnets}
|
||||
data, err := json.Marshal(params)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
path := fmt.Sprintf("%s/api/v2/device/%s/routes", c.baseURL(), deviceID)
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", path, bytes.NewBuffer(data))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
b, resp, err := c.sendRequest(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 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 {
|
||||
return nil, handleErrorResponse(b, resp)
|
||||
}
|
||||
|
||||
var srr *Routes
|
||||
if err := json.Unmarshal(b, &srr); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return srr, err
|
||||
}
|
||||
42
client/tailscale/tailnet.go
Normal file
42
client/tailscale/tailnet.go
Normal file
@@ -0,0 +1,42 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
//go:build go1.19
|
||||
|
||||
package tailscale
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
"tailscale.com/util/httpm"
|
||||
)
|
||||
|
||||
// TailnetDeleteRequest handles sending a DELETE request for a tailnet to control.
|
||||
func (c *Client) TailnetDeleteRequest(ctx context.Context, tailnetID string) (err error) {
|
||||
defer func() {
|
||||
if err != nil {
|
||||
err = fmt.Errorf("tailscale.DeleteTailnet: %w", err)
|
||||
}
|
||||
}()
|
||||
|
||||
path := fmt.Sprintf("%s/api/v2/tailnet/%s", c.baseURL(), url.PathEscape(string(tailnetID)))
|
||||
req, err := http.NewRequestWithContext(ctx, httpm.DELETE, path, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
c.setAuth(req)
|
||||
b, resp, err := c.sendRequest(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return handleErrorResponse(b, resp)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -1,519 +1,157 @@
|
||||
// Copyright (c) 2021 Tailscale Inc & AUTHORS All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style
|
||||
// license that can be found in the LICENSE file.
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
// Package tailscale contains Tailscale client code.
|
||||
//go:build go1.19
|
||||
|
||||
// Package tailscale contains Go clients for the Tailscale LocalAPI and
|
||||
// Tailscale control plane API.
|
||||
//
|
||||
// Warning: this package is in development and makes no API compatibility
|
||||
// promises as of 2022-04-29. It is subject to change at any time.
|
||||
package tailscale
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"go4.org/mem"
|
||||
"tailscale.com/client/tailscale/apitype"
|
||||
"tailscale.com/ipn"
|
||||
"tailscale.com/ipn/ipnstate"
|
||||
"tailscale.com/paths"
|
||||
"tailscale.com/safesocket"
|
||||
"tailscale.com/tailcfg"
|
||||
"tailscale.com/version"
|
||||
)
|
||||
|
||||
var (
|
||||
// TailscaledSocket is the tailscaled Unix socket. It's used by the TailscaledDialer.
|
||||
TailscaledSocket = paths.DefaultTailscaledSocket()
|
||||
|
||||
// TailscaledSocketSetExplicitly reports whether the user explicitly set TailscaledSocket.
|
||||
TailscaledSocketSetExplicitly bool
|
||||
|
||||
// TailscaledDialer is the DialContext func that connects to the local machine's
|
||||
// tailscaled or equivalent.
|
||||
TailscaledDialer = defaultDialer
|
||||
)
|
||||
|
||||
func defaultDialer(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
if addr != "local-tailscaled.sock:80" {
|
||||
return nil, fmt.Errorf("unexpected URL address %q", addr)
|
||||
}
|
||||
// TODO: make this part of a safesocket.ConnectionStrategy
|
||||
if !TailscaledSocketSetExplicitly {
|
||||
// On macOS, when dialing from non-sandboxed program to sandboxed GUI running
|
||||
// a TCP server on a random port, find the random port. For HTTP connections,
|
||||
// we don't send the token. It gets added in an HTTP Basic-Auth header.
|
||||
if port, _, err := safesocket.LocalTCPPortAndToken(); err == nil {
|
||||
var d net.Dialer
|
||||
return d.DialContext(ctx, "tcp", "localhost:"+strconv.Itoa(port))
|
||||
}
|
||||
}
|
||||
s := safesocket.DefaultConnectionStrategy(TailscaledSocket)
|
||||
// The user provided a non-default tailscaled socket address.
|
||||
// Connect only to exactly what they provided.
|
||||
s.UseFallback(false)
|
||||
return safesocket.Connect(s)
|
||||
}
|
||||
|
||||
var (
|
||||
// tsClient does HTTP requests to the local Tailscale daemon.
|
||||
// We lazily initialize the client in case the caller wants to
|
||||
// override TailscaledDialer.
|
||||
tsClient *http.Client
|
||||
tsClientOnce sync.Once
|
||||
)
|
||||
|
||||
// DoLocalRequest makes an HTTP request to the local machine's Tailscale daemon.
|
||||
// I_Acknowledge_This_API_Is_Unstable must be set true to use this package
|
||||
// for now. It was added 2022-04-29 when it was moved to this git repo
|
||||
// and will be removed when the public API has settled.
|
||||
//
|
||||
// URLs are of the form http://local-tailscaled.sock/localapi/v0/whois?ip=1.2.3.4.
|
||||
// TODO(bradfitz): remove this after the we're happy with the public API.
|
||||
var I_Acknowledge_This_API_Is_Unstable = false
|
||||
|
||||
// TODO: use url.PathEscape() for deviceID and tailnets when constructing requests.
|
||||
|
||||
const defaultAPIBase = "https://api.tailscale.com"
|
||||
|
||||
// maxSize is the maximum read size (10MB) of responses from the server.
|
||||
const maxReadSize = 10 << 20
|
||||
|
||||
// Client makes API calls to the Tailscale control plane API server.
|
||||
//
|
||||
// The hostname must be "local-tailscaled.sock", even though it
|
||||
// doesn't actually do any DNS lookup. The actual means of connecting to and
|
||||
// authenticating to the local Tailscale daemon vary by platform.
|
||||
// Use NewClient to instantiate one. Exported fields should be set before
|
||||
// the client is used and not changed thereafter.
|
||||
type Client struct {
|
||||
// tailnet is the globally unique identifier for a Tailscale network, such
|
||||
// as "example.com" or "user@gmail.com".
|
||||
tailnet string
|
||||
// auth is the authentication method to use for this client.
|
||||
// nil means none, which generally won't work, but won't crash.
|
||||
auth AuthMethod
|
||||
|
||||
// BaseURL optionally specifies an alternate API server to use.
|
||||
// If empty, "https://api.tailscale.com" is used.
|
||||
BaseURL string
|
||||
|
||||
// HTTPClient optionally specifies an alternate HTTP client to use.
|
||||
// If nil, http.DefaultClient is used.
|
||||
HTTPClient *http.Client
|
||||
}
|
||||
|
||||
func (c *Client) httpClient() *http.Client {
|
||||
if c.HTTPClient != nil {
|
||||
return c.HTTPClient
|
||||
}
|
||||
return http.DefaultClient
|
||||
}
|
||||
|
||||
func (c *Client) baseURL() string {
|
||||
if c.BaseURL != "" {
|
||||
return c.BaseURL
|
||||
}
|
||||
return defaultAPIBase
|
||||
}
|
||||
|
||||
// AuthMethod is the interface for API authentication methods.
|
||||
//
|
||||
// DoLocalRequest may mutate the request to add Authorization headers.
|
||||
func DoLocalRequest(req *http.Request) (*http.Response, error) {
|
||||
tsClientOnce.Do(func() {
|
||||
tsClient = &http.Client{
|
||||
Transport: &http.Transport{
|
||||
DialContext: TailscaledDialer,
|
||||
},
|
||||
}
|
||||
})
|
||||
if _, token, err := safesocket.LocalTCPPortAndToken(); err == nil {
|
||||
req.SetBasicAuth("", token)
|
||||
}
|
||||
return tsClient.Do(req)
|
||||
// Most users will use AuthKey.
|
||||
type AuthMethod interface {
|
||||
modifyRequest(req *http.Request)
|
||||
}
|
||||
|
||||
func doLocalRequestNiceError(req *http.Request) (*http.Response, error) {
|
||||
res, err := DoLocalRequest(req)
|
||||
if err == nil {
|
||||
if server := res.Header.Get("Tailscale-Version"); server != "" && server != version.Long && onVersionMismatch != nil {
|
||||
onVersionMismatch(version.Long, server)
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
if ue, ok := err.(*url.Error); ok {
|
||||
if oe, ok := ue.Err.(*net.OpError); ok && oe.Op == "dial" {
|
||||
path := req.URL.Path
|
||||
pathPrefix := path
|
||||
if i := strings.Index(path, "?"); i != -1 {
|
||||
pathPrefix = path[:i]
|
||||
}
|
||||
return nil, fmt.Errorf("Failed to connect to local Tailscale daemon for %s; %s Error: %w", pathPrefix, tailscaledConnectHint(), oe)
|
||||
}
|
||||
}
|
||||
return nil, err
|
||||
// APIKey is an AuthMethod for NewClient that authenticates requests
|
||||
// using an authkey.
|
||||
type APIKey string
|
||||
|
||||
func (ak APIKey) modifyRequest(req *http.Request) {
|
||||
req.SetBasicAuth(string(ak), "")
|
||||
}
|
||||
|
||||
type errorJSON struct {
|
||||
Error string
|
||||
func (c *Client) setAuth(r *http.Request) {
|
||||
if c.auth != nil {
|
||||
c.auth.modifyRequest(r)
|
||||
}
|
||||
}
|
||||
|
||||
// AccessDeniedError is an error due to permissions.
|
||||
type AccessDeniedError struct {
|
||||
err error
|
||||
}
|
||||
|
||||
func (e *AccessDeniedError) Error() string { return fmt.Sprintf("Access denied: %v", e.err) }
|
||||
func (e *AccessDeniedError) Unwrap() error { return e.err }
|
||||
|
||||
// IsAccessDeniedError reports whether err is or wraps an AccessDeniedError.
|
||||
func IsAccessDeniedError(err error) bool {
|
||||
var ae *AccessDeniedError
|
||||
return errors.As(err, &ae)
|
||||
}
|
||||
|
||||
// bestError returns either err, or if body contains a valid JSON
|
||||
// object of type errorJSON, its non-empty error body.
|
||||
func bestError(err error, body []byte) error {
|
||||
var j errorJSON
|
||||
if err := json.Unmarshal(body, &j); err == nil && j.Error != "" {
|
||||
return errors.New(j.Error)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func errorMessageFromBody(body []byte) string {
|
||||
var j errorJSON
|
||||
if err := json.Unmarshal(body, &j); err == nil && j.Error != "" {
|
||||
return j.Error
|
||||
}
|
||||
return strings.TrimSpace(string(body))
|
||||
}
|
||||
|
||||
var onVersionMismatch func(clientVer, serverVer string)
|
||||
|
||||
// SetVersionMismatchHandler sets f as the version mismatch handler
|
||||
// to be called when the client (the current process) has a version
|
||||
// number that doesn't match the server's declared version.
|
||||
func SetVersionMismatchHandler(f func(clientVer, serverVer string)) {
|
||||
onVersionMismatch = f
|
||||
}
|
||||
|
||||
func send(ctx context.Context, method, path string, wantStatus int, body io.Reader) ([]byte, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, method, "http://local-tailscaled.sock"+path, body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
res, err := doLocalRequestNiceError(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer res.Body.Close()
|
||||
slurp, err := ioutil.ReadAll(res.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if res.StatusCode != wantStatus {
|
||||
if res.StatusCode == 403 {
|
||||
return nil, &AccessDeniedError{errors.New(errorMessageFromBody(slurp))}
|
||||
}
|
||||
err := fmt.Errorf("HTTP %s: %s (expected %v)", res.Status, slurp, wantStatus)
|
||||
return nil, bestError(err, slurp)
|
||||
}
|
||||
return slurp, nil
|
||||
}
|
||||
|
||||
func get200(ctx context.Context, path string) ([]byte, error) {
|
||||
return send(ctx, "GET", path, 200, nil)
|
||||
}
|
||||
|
||||
// WhoIs returns the owner of the remoteAddr, which must be an IP or IP:port.
|
||||
func WhoIs(ctx context.Context, remoteAddr string) (*apitype.WhoIsResponse, error) {
|
||||
body, err := get200(ctx, "/localapi/v0/whois?addr="+url.QueryEscape(remoteAddr))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r := new(apitype.WhoIsResponse)
|
||||
if err := json.Unmarshal(body, r); err != nil {
|
||||
if max := 200; len(body) > max {
|
||||
body = append(body[:max], "..."...)
|
||||
}
|
||||
return nil, fmt.Errorf("failed to parse JSON WhoIsResponse from %q", body)
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
// Goroutines returns a dump of the Tailscale daemon's current goroutines.
|
||||
func Goroutines(ctx context.Context) ([]byte, error) {
|
||||
return get200(ctx, "/localapi/v0/goroutines")
|
||||
}
|
||||
|
||||
// DaemonMetrics returns the Tailscale daemon's metrics in
|
||||
// the Prometheus text exposition format.
|
||||
func DaemonMetrics(ctx context.Context) ([]byte, error) {
|
||||
return get200(ctx, "/localapi/v0/metrics")
|
||||
}
|
||||
|
||||
// Profile returns a pprof profile of the Tailscale daemon.
|
||||
func Profile(ctx context.Context, pprofType string, sec int) ([]byte, error) {
|
||||
var secArg string
|
||||
if sec < 0 || sec > 300 {
|
||||
return nil, errors.New("duration out of range")
|
||||
}
|
||||
if sec != 0 || pprofType == "profile" {
|
||||
secArg = fmt.Sprint(sec)
|
||||
}
|
||||
return get200(ctx, fmt.Sprintf("/localapi/v0/profile?name=%s&seconds=%v", url.QueryEscape(pprofType), secArg))
|
||||
}
|
||||
|
||||
// BugReport logs and returns a log marker that can be shared by the user with support.
|
||||
func BugReport(ctx context.Context, note string) (string, error) {
|
||||
body, err := send(ctx, "POST", "/localapi/v0/bugreport?note="+url.QueryEscape(note), 200, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return strings.TrimSpace(string(body)), nil
|
||||
}
|
||||
|
||||
// Status returns the Tailscale daemon's status.
|
||||
func Status(ctx context.Context) (*ipnstate.Status, error) {
|
||||
return status(ctx, "")
|
||||
}
|
||||
|
||||
// StatusWithoutPeers returns the Tailscale daemon's status, without the peer info.
|
||||
func StatusWithoutPeers(ctx context.Context) (*ipnstate.Status, error) {
|
||||
return status(ctx, "?peers=false")
|
||||
}
|
||||
|
||||
func status(ctx context.Context, queryString string) (*ipnstate.Status, error) {
|
||||
body, err := get200(ctx, "/localapi/v0/status"+queryString)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
st := new(ipnstate.Status)
|
||||
if err := json.Unmarshal(body, st); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return st, nil
|
||||
}
|
||||
|
||||
func WaitingFiles(ctx context.Context) ([]apitype.WaitingFile, error) {
|
||||
body, err := get200(ctx, "/localapi/v0/files/")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var wfs []apitype.WaitingFile
|
||||
if err := json.Unmarshal(body, &wfs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return wfs, nil
|
||||
}
|
||||
|
||||
func DeleteWaitingFile(ctx context.Context, baseName string) error {
|
||||
_, err := send(ctx, "DELETE", "/localapi/v0/files/"+url.PathEscape(baseName), http.StatusNoContent, nil)
|
||||
return err
|
||||
}
|
||||
|
||||
func GetWaitingFile(ctx context.Context, baseName string) (rc io.ReadCloser, size int64, err error) {
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", "http://local-tailscaled.sock/localapi/v0/files/"+url.PathEscape(baseName), nil)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
res, err := DoLocalRequest(req)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if res.ContentLength == -1 {
|
||||
res.Body.Close()
|
||||
return nil, 0, fmt.Errorf("unexpected chunking")
|
||||
}
|
||||
if res.StatusCode != 200 {
|
||||
body, _ := ioutil.ReadAll(res.Body)
|
||||
res.Body.Close()
|
||||
return nil, 0, fmt.Errorf("HTTP %s: %s", res.Status, body)
|
||||
}
|
||||
return res.Body, res.ContentLength, nil
|
||||
}
|
||||
|
||||
func FileTargets(ctx context.Context) ([]apitype.FileTarget, error) {
|
||||
body, err := get200(ctx, "/localapi/v0/file-targets")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var fts []apitype.FileTarget
|
||||
if err := json.Unmarshal(body, &fts); err != nil {
|
||||
return nil, fmt.Errorf("invalid JSON: %w", err)
|
||||
}
|
||||
return fts, nil
|
||||
}
|
||||
|
||||
// PushFile sends Taildrop file r to target.
|
||||
// NewClient is a convenience method for instantiating a new Client.
|
||||
//
|
||||
// A size of -1 means unknown.
|
||||
// The name parameter is the original filename, not escaped.
|
||||
func PushFile(ctx context.Context, target tailcfg.StableNodeID, size int64, name string, r io.Reader) error {
|
||||
req, err := http.NewRequestWithContext(ctx, "PUT", "http://local-tailscaled.sock/localapi/v0/file-put/"+string(target)+"/"+url.PathEscape(name), r)
|
||||
// tailnet is the globally unique identifier for a Tailscale network, such
|
||||
// as "example.com" or "user@gmail.com".
|
||||
// If httpClient is nil, then http.DefaultClient is used.
|
||||
// "api.tailscale.com" is set as the BaseURL for the returned client
|
||||
// and can be changed manually by the user.
|
||||
func NewClient(tailnet string, auth AuthMethod) *Client {
|
||||
return &Client{
|
||||
tailnet: tailnet,
|
||||
auth: auth,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) Tailnet() string { return c.tailnet }
|
||||
|
||||
// Do sends a raw HTTP request, after adding any authentication headers.
|
||||
func (c *Client) Do(req *http.Request) (*http.Response, error) {
|
||||
if !I_Acknowledge_This_API_Is_Unstable {
|
||||
return nil, errors.New("use of Client without setting I_Acknowledge_This_API_Is_Unstable")
|
||||
}
|
||||
c.setAuth(req)
|
||||
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)
|
||||
if err != nil {
|
||||
return nil, resp, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Read response. Limit the response to 10MB.
|
||||
body := io.LimitReader(resp.Body, maxReadSize+1)
|
||||
b, err := io.ReadAll(body)
|
||||
if len(b) > maxReadSize {
|
||||
err = errors.New("API response too large")
|
||||
}
|
||||
return b, resp, err
|
||||
}
|
||||
|
||||
// ErrResponse is the HTTP error returned by the Tailscale server.
|
||||
type ErrResponse struct {
|
||||
Status int
|
||||
Message string
|
||||
}
|
||||
|
||||
func (e ErrResponse) Error() string {
|
||||
return fmt.Sprintf("Status: %d, Message: %q", e.Status, e.Message)
|
||||
}
|
||||
|
||||
// handleErrorResponse decodes the error message from the server and returns
|
||||
// an ErrResponse from it.
|
||||
func handleErrorResponse(b []byte, resp *http.Response) error {
|
||||
var errResp ErrResponse
|
||||
if err := json.Unmarshal(b, &errResp); err != nil {
|
||||
return err
|
||||
}
|
||||
if size != -1 {
|
||||
req.ContentLength = size
|
||||
}
|
||||
res, err := doLocalRequestNiceError(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if res.StatusCode == 200 {
|
||||
io.Copy(io.Discard, res.Body)
|
||||
return nil
|
||||
}
|
||||
all, _ := io.ReadAll(res.Body)
|
||||
return fmt.Errorf("%s: %s", res.Status, all)
|
||||
}
|
||||
|
||||
func CheckIPForwarding(ctx context.Context) error {
|
||||
body, err := get200(ctx, "/localapi/v0/check-ip-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 check-ip-forwarding: %w", err)
|
||||
}
|
||||
if jres.Warning != "" {
|
||||
return errors.New(jres.Warning)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func GetPrefs(ctx context.Context) (*ipn.Prefs, error) {
|
||||
body, err := get200(ctx, "/localapi/v0/prefs")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var p ipn.Prefs
|
||||
if err := json.Unmarshal(body, &p); err != nil {
|
||||
return nil, fmt.Errorf("invalid prefs JSON: %w", err)
|
||||
}
|
||||
return &p, nil
|
||||
}
|
||||
|
||||
func EditPrefs(ctx context.Context, mp *ipn.MaskedPrefs) (*ipn.Prefs, error) {
|
||||
mpj, err := json.Marshal(mp)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
body, err := send(ctx, "PATCH", "/localapi/v0/prefs", http.StatusOK, bytes.NewReader(mpj))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var p ipn.Prefs
|
||||
if err := json.Unmarshal(body, &p); err != nil {
|
||||
return nil, fmt.Errorf("invalid prefs JSON: %w", err)
|
||||
}
|
||||
return &p, nil
|
||||
}
|
||||
|
||||
func Logout(ctx context.Context) error {
|
||||
_, err := send(ctx, "POST", "/localapi/v0/logout", http.StatusNoContent, nil)
|
||||
return err
|
||||
}
|
||||
|
||||
// SetDNS adds a DNS TXT record for the given domain name, containing
|
||||
// the provided TXT value. The intended use case is answering
|
||||
// LetsEncrypt/ACME dns-01 challenges.
|
||||
//
|
||||
// The control plane will only permit SetDNS requests with very
|
||||
// specific names and values. The name should be
|
||||
// "_acme-challenge." + your node's MagicDNS name. It's expected that
|
||||
// clients cache the certs from LetsEncrypt (or whichever CA is
|
||||
// providing them) and only request new ones as needed; the control plane
|
||||
// rate limits SetDNS requests.
|
||||
//
|
||||
// This is a low-level interface; it's expected that most Tailscale
|
||||
// users use a higher level interface to getting/using TLS
|
||||
// certificates.
|
||||
func SetDNS(ctx context.Context, name, value string) error {
|
||||
v := url.Values{}
|
||||
v.Set("name", name)
|
||||
v.Set("value", value)
|
||||
_, err := send(ctx, "POST", "/localapi/v0/set-dns?"+v.Encode(), 200, nil)
|
||||
return err
|
||||
}
|
||||
|
||||
// CurrentDERPMap returns the current DERPMap that is being used by the local tailscaled.
|
||||
// It is intended to be used with netcheck to see availability of DERPs.
|
||||
func CurrentDERPMap(ctx context.Context) (*tailcfg.DERPMap, error) {
|
||||
var derpMap tailcfg.DERPMap
|
||||
res, err := send(ctx, "GET", "/localapi/v0/derpmap", 200, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err = json.Unmarshal(res, &derpMap); err != nil {
|
||||
return nil, fmt.Errorf("invalid derp map json: %w", err)
|
||||
}
|
||||
return &derpMap, nil
|
||||
}
|
||||
|
||||
// CertPair returns a cert and private key for the provided DNS domain.
|
||||
//
|
||||
// It returns a cached certificate from disk if it's still valid.
|
||||
func CertPair(ctx context.Context, domain string) (certPEM, keyPEM []byte, err error) {
|
||||
res, err := send(ctx, "GET", "/localapi/v0/cert/"+domain+"?type=pair", 200, nil)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
// with ?type=pair, the response PEM is first the one private
|
||||
// key PEM block, then the cert PEM blocks.
|
||||
i := mem.Index(mem.B(res), mem.S("--\n--"))
|
||||
if i == -1 {
|
||||
return nil, nil, fmt.Errorf("unexpected output: no delimiter")
|
||||
}
|
||||
i += len("--\n")
|
||||
keyPEM, certPEM = res[:i], res[i:]
|
||||
if mem.Contains(mem.B(certPEM), mem.S(" PRIVATE KEY-----")) {
|
||||
return nil, nil, fmt.Errorf("unexpected output: key in cert")
|
||||
}
|
||||
return certPEM, keyPEM, nil
|
||||
}
|
||||
|
||||
// GetCertificate fetches a TLS certificate for the TLS ClientHello in hi.
|
||||
//
|
||||
// It returns a cached certificate from disk if it's still valid.
|
||||
//
|
||||
// It's the right signature to use as the value of
|
||||
// tls.Config.GetCertificate.
|
||||
func GetCertificate(hi *tls.ClientHelloInfo) (*tls.Certificate, error) {
|
||||
if hi == nil || hi.ServerName == "" {
|
||||
return nil, errors.New("no SNI ServerName")
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
|
||||
defer cancel()
|
||||
|
||||
name := hi.ServerName
|
||||
if !strings.Contains(name, ".") {
|
||||
if v, ok := ExpandSNIName(ctx, name); ok {
|
||||
name = v
|
||||
}
|
||||
}
|
||||
certPEM, keyPEM, err := CertPair(ctx, name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cert, err := tls.X509KeyPair(certPEM, keyPEM)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &cert, nil
|
||||
}
|
||||
|
||||
// ExpandSNIName expands bare label name into the the most likely actual TLS cert name.
|
||||
func ExpandSNIName(ctx context.Context, name string) (fqdn string, ok bool) {
|
||||
st, err := StatusWithoutPeers(ctx)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
for _, d := range st.CertDomains {
|
||||
if len(d) > len(name)+1 && strings.HasPrefix(d, name) && d[len(name)] == '.' {
|
||||
return d, true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// tailscaledConnectHint gives a little thing about why tailscaled (or
|
||||
// platform equivalent) is not answering localapi connections.
|
||||
//
|
||||
// It ends in a punctuation. See caller.
|
||||
func tailscaledConnectHint() string {
|
||||
if runtime.GOOS != "linux" {
|
||||
// TODO(bradfitz): flesh this out
|
||||
return "not running?"
|
||||
}
|
||||
out, err := exec.Command("systemctl", "show", "tailscaled.service", "--no-page", "--property", "LoadState,ActiveState,SubState").Output()
|
||||
if err != nil {
|
||||
return "not running?"
|
||||
}
|
||||
// Parse:
|
||||
// LoadState=loaded
|
||||
// ActiveState=inactive
|
||||
// SubState=dead
|
||||
st := map[string]string{}
|
||||
for _, line := range strings.Split(string(out), "\n") {
|
||||
if i := strings.Index(line, "="); i != -1 {
|
||||
st[line[:i]] = strings.TrimSpace(line[i+1:])
|
||||
}
|
||||
}
|
||||
if st["LoadState"] == "loaded" &&
|
||||
(st["SubState"] != "running" || st["ActiveState"] != "active") {
|
||||
return "systemd tailscaled.service not running."
|
||||
}
|
||||
return "not running?"
|
||||
errResp.Status = resp.StatusCode
|
||||
return errResp
|
||||
}
|
||||
|
||||
85
client/web/assets.go
Normal file
85
client/web/assets.go
Normal file
@@ -0,0 +1,85 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package web
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
prebuilt "github.com/tailscale/web-client-prebuilt"
|
||||
)
|
||||
|
||||
func assetsHandler(devMode bool) (_ http.Handler, cleanup func()) {
|
||||
if devMode {
|
||||
// When in dev mode, proxy asset requests to the Vite dev server.
|
||||
cleanup := startDevServer()
|
||||
return devServerProxy(), cleanup
|
||||
}
|
||||
return http.FileServer(http.FS(prebuilt.FS())), nil
|
||||
}
|
||||
|
||||
// startDevServer starts the JS dev server that does on-demand rebuilding
|
||||
// and serving of web client JS and CSS resources.
|
||||
func startDevServer() (cleanup func()) {
|
||||
root := gitRootDir()
|
||||
webClientPath := filepath.Join(root, "client", "web")
|
||||
|
||||
yarn := filepath.Join(root, "tool", "yarn")
|
||||
node := filepath.Join(root, "tool", "node")
|
||||
vite := filepath.Join(webClientPath, "node_modules", ".bin", "vite")
|
||||
|
||||
log.Printf("installing JavaScript deps using %s... (might take ~30s)", yarn)
|
||||
out, err := exec.Command(yarn, "--non-interactive", "-s", "--cwd", webClientPath, "install").CombinedOutput()
|
||||
if err != nil {
|
||||
log.Fatalf("error running tailscale web's yarn install: %v, %s", err, out)
|
||||
}
|
||||
log.Printf("starting JavaScript dev server...")
|
||||
cmd := exec.Command(node, vite)
|
||||
cmd.Dir = webClientPath
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
if err := cmd.Start(); err != nil {
|
||||
log.Fatalf("Starting JS dev server: %v", err)
|
||||
}
|
||||
log.Printf("JavaScript dev server running as pid %d", cmd.Process.Pid)
|
||||
return func() {
|
||||
cmd.Process.Signal(os.Interrupt)
|
||||
err := cmd.Wait()
|
||||
log.Printf("JavaScript dev server exited: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// devServerProxy returns a reverse proxy to the vite dev server.
|
||||
func devServerProxy() *httputil.ReverseProxy {
|
||||
// We use Vite to develop on the web client.
|
||||
// Vite starts up its own local server for development,
|
||||
// which we proxy requests to from Server.ServeHTTP.
|
||||
// Here we set up the proxy to Vite's server.
|
||||
handleErr := func(w http.ResponseWriter, r *http.Request, err error) {
|
||||
w.Header().Set("Content-Type", "text/plain")
|
||||
w.WriteHeader(http.StatusBadGateway)
|
||||
w.Write([]byte("The web client development server isn't running. " +
|
||||
"Run `./tool/yarn --cwd client/web start` from " +
|
||||
"the repo root to start the development server."))
|
||||
w.Write([]byte("\n\nError: " + err.Error()))
|
||||
}
|
||||
viteTarget, _ := url.Parse("http://127.0.0.1:4000")
|
||||
devProxy := httputil.NewSingleHostReverseProxy(viteTarget)
|
||||
devProxy.ErrorHandler = handleErr
|
||||
return devProxy
|
||||
}
|
||||
|
||||
func gitRootDir() string {
|
||||
top, err := exec.Command("git", "rev-parse", "--show-toplevel").Output()
|
||||
if err != nil {
|
||||
log.Fatalf("failed to find git top level (not in corp git?): %v", err)
|
||||
}
|
||||
return strings.TrimSpace(string(top))
|
||||
}
|
||||
28
client/web/build/index.html
Normal file
28
client/web/build/index.html
Normal file
@@ -0,0 +1,28 @@
|
||||
<!doctype html>
|
||||
<html class="bg-gray-50">
|
||||
<head>
|
||||
<title>Tailscale</title>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="shortcut icon" href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAQAAADZc7J/AAAABGdBTUEAALGPC/xhBQAAACBjSFJNAAB6JgAAgIQAAPoAAACA6AAAdTAAAOpgAAA6mAAAF3CculE8AAAAAmJLR0QA/4ePzL8AAAAHdElNRQflAx4QGA4EvmzDAAAA30lEQVRIx2NgGAWMCKa8JKM4A8Ovt88ekyLCDGOoyDBJMjExMbFy8zF8/EKsCAMDE8yAPyIwFps48SJIBpAL4AZwvoSx/r0lXgQpDN58EWL5x/7/H+vL20+JFxluQKVe5b3Ke5V+0kQQCamfoYKBg4GDwUKI8d0BYkWQkrLKewYBKPPDHUFiRaiZkBgmwhj/F5IgggyUJ6i8V3mv0kCayDAAeEsklXqGAgYGhgV3CnGrwVciYSYk0kokhgS44/JxqqFpiYSZbEgskd4dEBRk1GD4wdB5twKXmlHAwMDAAACdEZau06NQUwAAACV0RVh0ZGF0ZTpjcmVhdGUAMjAyMC0wNy0xNVQxNTo1Mzo0MCswMDowMCVXsDIAAAAldEVYdGRhdGU6bW9kaWZ5ADIwMjAtMDctMTVUMTU6NTM6NDArMDA6MDBUCgiOAAAAAElFTkSuQmCC" />
|
||||
|
||||
<script type="module" crossorigin src="./assets/index-4d1f45ea.js"></script>
|
||||
<link rel="stylesheet" href="./assets/index-8612dca6.css">
|
||||
</head>
|
||||
<body>
|
||||
<noscript>
|
||||
<p class="mb-2">You need to enable Javascript to access the Tailscale web client.</p>
|
||||
<p>If you need any help, feel free to <a href="mailto:support+webclient@tailscale.com" class="link">contact us</a>.</p>
|
||||
</noscript>
|
||||
|
||||
<script>
|
||||
window.addEventListener("load", () => {
|
||||
if (!window.Tailscale) {
|
||||
const rootEl = document.createElement("p")
|
||||
rootEl.innerHTML = 'Tailscale was built without the web client. See <a href="https://github.com/tailscale/tailscale#building-the-web-client">Building the web client</a> for more information.'
|
||||
document.body.append(rootEl)
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
26
client/web/index.html
Normal file
26
client/web/index.html
Normal file
@@ -0,0 +1,26 @@
|
||||
<!doctype html>
|
||||
<html class="bg-gray-50">
|
||||
<head>
|
||||
<title>Tailscale</title>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="shortcut icon" href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAQAAADZc7J/AAAABGdBTUEAALGPC/xhBQAAACBjSFJNAAB6JgAAgIQAAPoAAACA6AAAdTAAAOpgAAA6mAAAF3CculE8AAAAAmJLR0QA/4ePzL8AAAAHdElNRQflAx4QGA4EvmzDAAAA30lEQVRIx2NgGAWMCKa8JKM4A8Ovt88ekyLCDGOoyDBJMjExMbFy8zF8/EKsCAMDE8yAPyIwFps48SJIBpAL4AZwvoSx/r0lXgQpDN58EWL5x/7/H+vL20+JFxluQKVe5b3Ke5V+0kQQCamfoYKBg4GDwUKI8d0BYkWQkrLKewYBKPPDHUFiRaiZkBgmwhj/F5IgggyUJ6i8V3mv0kCayDAAeEsklXqGAgYGhgV3CnGrwVciYSYk0kokhgS44/JxqqFpiYSZbEgskd4dEBRk1GD4wdB5twKXmlHAwMDAAACdEZau06NQUwAAACV0RVh0ZGF0ZTpjcmVhdGUAMjAyMC0wNy0xNVQxNTo1Mzo0MCswMDowMCVXsDIAAAAldEVYdGRhdGU6bW9kaWZ5ADIwMjAtMDctMTVUMTU6NTM6NDArMDA6MDBUCgiOAAAAAElFTkSuQmCC" />
|
||||
<link rel="stylesheet" type="text/css" href="/src/index.css" />
|
||||
</head>
|
||||
<body>
|
||||
<noscript>
|
||||
<p class="mb-2">You need to enable Javascript to access the Tailscale web client.</p>
|
||||
<p>If you need any help, feel free to <a href="mailto:support+webclient@tailscale.com" class="link">contact us</a>.</p>
|
||||
</noscript>
|
||||
<script type="module" src="/src/index.tsx"></script>
|
||||
<script>
|
||||
window.addEventListener("load", () => {
|
||||
if (!window.Tailscale) {
|
||||
const rootEl = document.createElement("p")
|
||||
rootEl.innerHTML = 'Tailscale was built without the web client. See <a href="https://github.com/tailscale/tailscale#building-the-web-client">Building the web client</a> for more information.'
|
||||
document.body.append(rootEl)
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
44
client/web/package.json
Normal file
44
client/web/package.json
Normal file
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"name": "webclient",
|
||||
"version": "0.0.1",
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": "18.16.1",
|
||||
"yarn": "1.22.19"
|
||||
},
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"classnames": "^2.3.1",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/classnames": "^2.2.10",
|
||||
"@types/react": "^18.0.20",
|
||||
"@types/react-dom": "^18.0.6",
|
||||
"@vitejs/plugin-react-swc": "^3.3.2",
|
||||
"autoprefixer": "^10.4.15",
|
||||
"postcss": "^8.4.27",
|
||||
"prettier": "^2.5.1",
|
||||
"prettier-plugin-organize-imports": "^3.2.2",
|
||||
"tailwindcss": "^3.3.3",
|
||||
"typescript": "^4.7.4",
|
||||
"vite": "^4.3.9",
|
||||
"vite-plugin-rewrite-all": "^1.0.1",
|
||||
"vite-plugin-svgr": "^3.2.0",
|
||||
"vite-tsconfig-paths": "^3.5.0",
|
||||
"vitest": "^0.32.0"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"start": "vite",
|
||||
"lint": "tsc --noEmit",
|
||||
"test": "vitest",
|
||||
"format": "prettier --write 'src/**/*.{ts,tsx}'",
|
||||
"format-check": "prettier --check 'src/**/*.{ts,tsx}'"
|
||||
},
|
||||
"prettier": {
|
||||
"semi": false,
|
||||
"printWidth": 80
|
||||
}
|
||||
}
|
||||
6
client/web/postcss.config.js
Normal file
6
client/web/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
130
client/web/qnap.go
Normal file
130
client/web/qnap.go
Normal file
@@ -0,0 +1,130 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
// qnap.go contains handlers and logic, such as authentication,
|
||||
// that is specific to running the web client on QNAP.
|
||||
|
||||
package web
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
)
|
||||
|
||||
// authorizeQNAP authenticates the logged-in QNAP user and verifies that they
|
||||
// are authorized to use the web client.
|
||||
// It reports true if the request is authorized to continue, and false otherwise.
|
||||
// authorizeQNAP manages writing out any relevant authorization errors to the
|
||||
// ResponseWriter itself.
|
||||
func authorizeQNAP(w http.ResponseWriter, r *http.Request) (ok bool) {
|
||||
_, resp, err := qnapAuthn(r)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusUnauthorized)
|
||||
return false
|
||||
}
|
||||
if resp.IsAdmin == 0 {
|
||||
http.Error(w, "user is not an admin", http.StatusForbidden)
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
type qnapAuthResponse struct {
|
||||
AuthPassed int `xml:"authPassed"`
|
||||
IsAdmin int `xml:"isAdmin"`
|
||||
AuthSID string `xml:"authSid"`
|
||||
ErrorValue int `xml:"errorValue"`
|
||||
}
|
||||
|
||||
func qnapAuthn(r *http.Request) (string, *qnapAuthResponse, error) {
|
||||
user, err := r.Cookie("NAS_USER")
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
token, err := r.Cookie("qtoken")
|
||||
if err == nil {
|
||||
return qnapAuthnQtoken(r, user.Value, token.Value)
|
||||
}
|
||||
sid, err := r.Cookie("NAS_SID")
|
||||
if err == nil {
|
||||
return qnapAuthnSid(r, user.Value, sid.Value)
|
||||
}
|
||||
return "", nil, fmt.Errorf("not authenticated by any mechanism")
|
||||
}
|
||||
|
||||
// qnapAuthnURL returns the auth URL to use by inferring where the UI is
|
||||
// running based on the request URL. This is necessary because QNAP has so
|
||||
// many options, see https://github.com/tailscale/tailscale/issues/7108
|
||||
// and https://github.com/tailscale/tailscale/issues/6903
|
||||
func qnapAuthnURL(requestUrl string, query url.Values) string {
|
||||
in, err := url.Parse(requestUrl)
|
||||
scheme := ""
|
||||
host := ""
|
||||
if err != nil || in.Scheme == "" {
|
||||
log.Printf("Cannot parse QNAP login URL %v", err)
|
||||
|
||||
// try localhost and hope for the best
|
||||
scheme = "http"
|
||||
host = "localhost"
|
||||
} else {
|
||||
scheme = in.Scheme
|
||||
host = in.Host
|
||||
}
|
||||
|
||||
u := url.URL{
|
||||
Scheme: scheme,
|
||||
Host: host,
|
||||
Path: "/cgi-bin/authLogin.cgi",
|
||||
RawQuery: query.Encode(),
|
||||
}
|
||||
|
||||
return u.String()
|
||||
}
|
||||
|
||||
func qnapAuthnQtoken(r *http.Request, user, token string) (string, *qnapAuthResponse, error) {
|
||||
query := url.Values{
|
||||
"qtoken": []string{token},
|
||||
"user": []string{user},
|
||||
}
|
||||
return qnapAuthnFinish(user, qnapAuthnURL(r.URL.String(), query))
|
||||
}
|
||||
|
||||
func qnapAuthnSid(r *http.Request, user, sid string) (string, *qnapAuthResponse, error) {
|
||||
query := url.Values{
|
||||
"sid": []string{sid},
|
||||
}
|
||||
return qnapAuthnFinish(user, qnapAuthnURL(r.URL.String(), query))
|
||||
}
|
||||
|
||||
func qnapAuthnFinish(user, url string) (string, *qnapAuthResponse, error) {
|
||||
// QNAP Force HTTPS mode uses a self-signed certificate. Even importing
|
||||
// the QNAP root CA isn't enough, the cert doesn't have a usable CN nor
|
||||
// SAN. See https://github.com/tailscale/tailscale/issues/6903
|
||||
tr := &http.Transport{
|
||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
|
||||
}
|
||||
client := &http.Client{Transport: tr}
|
||||
resp, err := client.Get(url)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
out, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
authResp := &qnapAuthResponse{}
|
||||
if err := xml.Unmarshal(out, authResp); err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
if authResp.AuthPassed == 0 {
|
||||
return "", nil, fmt.Errorf("not authenticated")
|
||||
}
|
||||
return user, authResp, nil
|
||||
}
|
||||
67
client/web/src/api.ts
Normal file
67
client/web/src/api.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
let csrfToken: string
|
||||
let unraidCsrfToken: string | undefined // required for unraid POST requests (#8062)
|
||||
|
||||
// apiFetch wraps the standard JS fetch function with csrf header
|
||||
// management and param additions specific to the web client.
|
||||
//
|
||||
// apiFetch adds the `api` prefix to the request URL,
|
||||
// so endpoint should be provided without the `api` prefix
|
||||
// (i.e. provide `/data` rather than `api/data`).
|
||||
export function apiFetch(
|
||||
endpoint: string,
|
||||
method: "GET" | "POST",
|
||||
body?: any,
|
||||
params?: Record<string, string>
|
||||
): Promise<Response> {
|
||||
const urlParams = new URLSearchParams(window.location.search)
|
||||
const nextParams = new URLSearchParams(params)
|
||||
const token = urlParams.get("SynoToken")
|
||||
if (token) {
|
||||
nextParams.set("SynoToken", token)
|
||||
}
|
||||
const search = nextParams.toString()
|
||||
const url = `api${endpoint}${search ? `?${search}` : ""}`
|
||||
|
||||
var contentType: string
|
||||
if (unraidCsrfToken && method === "POST") {
|
||||
const params = new URLSearchParams()
|
||||
params.append("csrf_token", unraidCsrfToken)
|
||||
if (body) {
|
||||
params.append("ts_data", JSON.stringify(body))
|
||||
}
|
||||
body = params.toString()
|
||||
contentType = "application/x-www-form-urlencoded;charset=UTF-8"
|
||||
} else {
|
||||
body = body ? JSON.stringify(body) : undefined
|
||||
contentType = "application/json"
|
||||
}
|
||||
|
||||
return fetch(url, {
|
||||
method: method,
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
"Content-Type": contentType,
|
||||
"X-CSRF-Token": csrfToken,
|
||||
},
|
||||
body,
|
||||
}).then((r) => {
|
||||
updateCsrfToken(r)
|
||||
if (!r.ok) {
|
||||
return r.text().then((err) => {
|
||||
throw new Error(err)
|
||||
})
|
||||
}
|
||||
return r
|
||||
})
|
||||
}
|
||||
|
||||
function updateCsrfToken(r: Response) {
|
||||
const tok = r.headers.get("X-CSRF-Token")
|
||||
if (tok) {
|
||||
csrfToken = tok
|
||||
}
|
||||
}
|
||||
|
||||
export function setUnraidCsrfToken(token?: string) {
|
||||
unraidCsrfToken = token
|
||||
}
|
||||
156
client/web/src/components/app.tsx
Normal file
156
client/web/src/components/app.tsx
Normal file
@@ -0,0 +1,156 @@
|
||||
import React from "react"
|
||||
import { Footer, Header, IP, State } from "src/components/legacy"
|
||||
import useAuth, { AuthResponse } from "src/hooks/auth"
|
||||
import useNodeData, { NodeData } from "src/hooks/node-data"
|
||||
import { ReactComponent as ConnectedDeviceIcon } from "src/icons/connected-device.svg"
|
||||
import { ReactComponent as TailscaleIcon } from "src/icons/tailscale-icon.svg"
|
||||
import { ReactComponent as TailscaleLogo } from "src/icons/tailscale-logo.svg"
|
||||
|
||||
export default function App() {
|
||||
// TODO(sonia): use isPosting value from useNodeData
|
||||
// to fill loading states.
|
||||
const { data, refreshData, updateNode } = useNodeData()
|
||||
|
||||
if (!data) {
|
||||
// TODO(sonia): add a loading view
|
||||
return <div className="text-center py-14">Loading...</div>
|
||||
}
|
||||
|
||||
const needsLogin = data?.Status === "NeedsLogin" || data?.Status === "NoState"
|
||||
|
||||
return !needsLogin &&
|
||||
(data.DebugMode === "login" || data.DebugMode === "full") ? (
|
||||
<WebClient {...data} />
|
||||
) : (
|
||||
// Legacy client UI
|
||||
<div className="py-14">
|
||||
<main className="container max-w-lg mx-auto mb-8 py-6 px-8 bg-white rounded-md shadow-2xl">
|
||||
<Header data={data} refreshData={refreshData} updateNode={updateNode} />
|
||||
<IP data={data} />
|
||||
<State data={data} updateNode={updateNode} />
|
||||
</main>
|
||||
<Footer licensesURL={data.LicensesURL} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function WebClient(props: NodeData) {
|
||||
const { data: auth, loading: loadingAuth, waitOnAuth } = useAuth()
|
||||
|
||||
if (loadingAuth) {
|
||||
return <div className="text-center py-14">Loading...</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center min-w-sm max-w-lg mx-auto py-10">
|
||||
{props.DebugMode === "full" && auth?.ok ? (
|
||||
<ManagementView {...props} />
|
||||
) : (
|
||||
<ReadonlyView data={props} auth={auth} waitOnAuth={waitOnAuth} />
|
||||
)}
|
||||
<Footer className="mt-20" licensesURL={props.LicensesURL} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ReadonlyView({
|
||||
data,
|
||||
auth,
|
||||
waitOnAuth,
|
||||
}: {
|
||||
data: NodeData
|
||||
auth?: AuthResponse
|
||||
waitOnAuth: () => Promise<void>
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<div className="pb-52 mx-auto">
|
||||
<TailscaleLogo />
|
||||
</div>
|
||||
<div className="w-full p-4 bg-stone-50 rounded-3xl border border-gray-200 flex flex-col gap-4">
|
||||
<div className="flex gap-2.5">
|
||||
<ProfilePic url={data.Profile.ProfilePicURL} />
|
||||
<div className="font-medium">
|
||||
<div className="text-neutral-500 text-xs uppercase tracking-wide">
|
||||
Owned by
|
||||
</div>
|
||||
<div className="text-neutral-800 text-sm leading-tight">
|
||||
{/* TODO(sonia): support tagged node profile view more eloquently */}
|
||||
{data.Profile.LoginName}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-5 py-4 bg-white rounded-lg border border-gray-200 justify-between items-center flex">
|
||||
<div className="flex gap-3">
|
||||
<ConnectedDeviceIcon />
|
||||
<div className="text-neutral-800">
|
||||
<div className="text-lg font-medium leading-[25.20px]">
|
||||
{data.DeviceName}
|
||||
</div>
|
||||
<div className="text-sm leading-tight">{data.IP}</div>
|
||||
</div>
|
||||
</div>
|
||||
{data.DebugMode === "full" && (
|
||||
<button
|
||||
className="button button-blue ml-6"
|
||||
onClick={() => {
|
||||
window.open(auth?.authUrl, "_blank")
|
||||
waitOnAuth()
|
||||
}}
|
||||
>
|
||||
Access
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function ManagementView(props: NodeData) {
|
||||
return (
|
||||
<div className="px-5">
|
||||
<div className="flex justify-between mb-12">
|
||||
<TailscaleIcon />
|
||||
<div className="flex">
|
||||
<p className="mr-2">{props.Profile.LoginName}</p>
|
||||
{/* TODO(sonia): support tagged node profile view more eloquently */}
|
||||
<ProfilePic url={props.Profile.ProfilePicURL} />
|
||||
</div>
|
||||
</div>
|
||||
<p className="tracking-wide uppercase text-gray-600 pb-3">This device</p>
|
||||
<div className="-mx-5 border rounded-md px-5 py-4 bg-white">
|
||||
<div className="flex justify-between items-center text-lg">
|
||||
<div className="flex items-center">
|
||||
<ConnectedDeviceIcon />
|
||||
<p className="font-medium ml-3">{props.DeviceName}</p>
|
||||
</div>
|
||||
<p className="tracking-widest">{props.IP}</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-gray-500 pt-2">
|
||||
Tailscale is up and running. You can connect to this device from devices
|
||||
in your tailnet by using its name or IP address.
|
||||
</p>
|
||||
<button className="button button-blue mt-6">Advertise exit node</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ProfilePic({ url }: { url: string }) {
|
||||
return (
|
||||
<div className="relative flex-shrink-0 w-8 h-8 rounded-full overflow-hidden">
|
||||
{url ? (
|
||||
<div
|
||||
className="w-8 h-8 flex pointer-events-none rounded-full bg-gray-200"
|
||||
style={{
|
||||
backgroundImage: `url(${url})`,
|
||||
backgroundSize: "cover",
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className="w-8 h-8 flex pointer-events-none rounded-full border border-gray-400 border-dashed" />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
298
client/web/src/components/legacy.tsx
Normal file
298
client/web/src/components/legacy.tsx
Normal file
@@ -0,0 +1,298 @@
|
||||
import cx from "classnames"
|
||||
import React from "react"
|
||||
import { apiFetch } from "src/api"
|
||||
import { NodeData, NodeUpdate } from "src/hooks/node-data"
|
||||
|
||||
// TODO(tailscale/corp#13775): legacy.tsx contains a set of components
|
||||
// that (crudely) implement the pre-2023 web client. These are implemented
|
||||
// purely to ease migration to the new React-based web client, and will
|
||||
// eventually be completely removed.
|
||||
|
||||
export function Header({
|
||||
data,
|
||||
refreshData,
|
||||
updateNode,
|
||||
}: {
|
||||
data: NodeData
|
||||
refreshData: () => void
|
||||
updateNode: (update: NodeUpdate) => void
|
||||
}) {
|
||||
return (
|
||||
<header className="flex justify-between items-center min-width-0 py-2 mb-8">
|
||||
<svg
|
||||
width="26"
|
||||
height="26"
|
||||
viewBox="0 0 23 23"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="flex-shrink-0 mr-4"
|
||||
>
|
||||
<circle
|
||||
opacity="0.2"
|
||||
cx="3.4"
|
||||
cy="3.25"
|
||||
r="2.7"
|
||||
fill="currentColor"
|
||||
></circle>
|
||||
<circle cx="3.4" cy="11.3" r="2.7" fill="currentColor"></circle>
|
||||
<circle
|
||||
opacity="0.2"
|
||||
cx="3.4"
|
||||
cy="19.5"
|
||||
r="2.7"
|
||||
fill="currentColor"
|
||||
></circle>
|
||||
<circle cx="11.5" cy="11.3" r="2.7" fill="currentColor"></circle>
|
||||
<circle cx="11.5" cy="19.5" r="2.7" fill="currentColor"></circle>
|
||||
<circle
|
||||
opacity="0.2"
|
||||
cx="11.5"
|
||||
cy="3.25"
|
||||
r="2.7"
|
||||
fill="currentColor"
|
||||
></circle>
|
||||
<circle
|
||||
opacity="0.2"
|
||||
cx="19.5"
|
||||
cy="3.25"
|
||||
r="2.7"
|
||||
fill="currentColor"
|
||||
></circle>
|
||||
<circle cx="19.5" cy="11.3" r="2.7" fill="currentColor"></circle>
|
||||
<circle
|
||||
opacity="0.2"
|
||||
cx="19.5"
|
||||
cy="19.5"
|
||||
r="2.7"
|
||||
fill="currentColor"
|
||||
></circle>
|
||||
</svg>
|
||||
<div className="flex items-center justify-end space-x-2 w-2/3">
|
||||
{data.Profile &&
|
||||
data.Status !== "NoState" &&
|
||||
data.Status !== "NeedsLogin" && (
|
||||
<>
|
||||
<div className="text-right w-full leading-4">
|
||||
<h4 className="truncate leading-normal">
|
||||
{data.Profile.LoginName}
|
||||
</h4>
|
||||
<div className="text-xs text-gray-500 text-right">
|
||||
<button
|
||||
onClick={() => updateNode({ Reauthenticate: true })}
|
||||
className="hover:text-gray-700"
|
||||
>
|
||||
Switch account
|
||||
</button>{" "}
|
||||
|{" "}
|
||||
<button
|
||||
onClick={() => updateNode({ Reauthenticate: true })}
|
||||
className="hover:text-gray-700"
|
||||
>
|
||||
Reauthenticate
|
||||
</button>{" "}
|
||||
|{" "}
|
||||
<button
|
||||
onClick={() =>
|
||||
apiFetch("/local/v0/logout", "POST")
|
||||
.then(refreshData)
|
||||
.catch((err) => alert("Logout failed: " + err.message))
|
||||
}
|
||||
className="hover:text-gray-700"
|
||||
>
|
||||
Logout
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative flex-shrink-0 w-8 h-8 rounded-full overflow-hidden">
|
||||
{data.Profile.ProfilePicURL ? (
|
||||
<div
|
||||
className="w-8 h-8 flex pointer-events-none rounded-full bg-gray-200"
|
||||
style={{
|
||||
backgroundImage: `url(${data.Profile.ProfilePicURL})`,
|
||||
backgroundSize: "cover",
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className="w-8 h-8 flex pointer-events-none rounded-full border border-gray-400 border-dashed" />
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
|
||||
export function IP(props: { data: NodeData }) {
|
||||
const { data } = props
|
||||
|
||||
if (!data.IP) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="border border-gray-200 bg-gray-50 rounded-md p-2 pl-3 pr-3 width-full flex items-center justify-between">
|
||||
<div className="flex items-center min-width-0">
|
||||
<svg
|
||||
className="flex-shrink-0 text-gray-600 mr-3 ml-1"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<rect x="2" y="2" width="20" height="8" rx="2" ry="2"></rect>
|
||||
<rect x="2" y="14" width="20" height="8" rx="2" ry="2"></rect>
|
||||
<line x1="6" y1="6" x2="6.01" y2="6"></line>
|
||||
<line x1="6" y1="18" x2="6.01" y2="18"></line>
|
||||
</svg>
|
||||
<h4 className="font-semibold truncate mr-2">
|
||||
{data.DeviceName || "Your device"}
|
||||
</h4>
|
||||
</div>
|
||||
<h5>{data.IP}</h5>
|
||||
</div>
|
||||
<p className="mt-1 ml-1 mb-6 text-xs text-gray-600">
|
||||
Debug info: Tailscale {data.IPNVersion}, tun={data.TUNMode.toString()}
|
||||
{data.IsSynology && (
|
||||
<>
|
||||
, DSM{data.DSMVersion}
|
||||
{data.TUNMode || (
|
||||
<>
|
||||
{" "}
|
||||
(
|
||||
<a
|
||||
href="https://tailscale.com/kb/1152/synology-outbound/"
|
||||
className="link-underline text-gray-600"
|
||||
target="_blank"
|
||||
aria-label="Configure outbound synology traffic"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
outgoing access not configured
|
||||
</a>
|
||||
)
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function State({
|
||||
data,
|
||||
updateNode,
|
||||
}: {
|
||||
data: NodeData
|
||||
updateNode: (update: NodeUpdate) => void
|
||||
}) {
|
||||
switch (data.Status) {
|
||||
case "NeedsLogin":
|
||||
case "NoState":
|
||||
if (data.IP) {
|
||||
return (
|
||||
<>
|
||||
<div className="mb-6">
|
||||
<p className="text-gray-700">
|
||||
Your device's key has expired. Reauthenticate this device by
|
||||
logging in again, or{" "}
|
||||
<a
|
||||
href="https://tailscale.com/kb/1028/key-expiry"
|
||||
className="link"
|
||||
target="_blank"
|
||||
>
|
||||
learn more
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => updateNode({ Reauthenticate: true })}
|
||||
className="button button-blue w-full mb-4"
|
||||
>
|
||||
Reauthenticate
|
||||
</button>
|
||||
</>
|
||||
)
|
||||
} else {
|
||||
return (
|
||||
<>
|
||||
<div className="mb-6">
|
||||
<h3 className="text-3xl font-semibold mb-3">Log in</h3>
|
||||
<p className="text-gray-700">
|
||||
Get started by logging in to your Tailscale network.
|
||||
Or, learn more at{" "}
|
||||
<a
|
||||
href="https://tailscale.com/"
|
||||
className="link"
|
||||
target="_blank"
|
||||
>
|
||||
tailscale.com
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => updateNode({ Reauthenticate: true })}
|
||||
className="button button-blue w-full mb-4"
|
||||
>
|
||||
Log In
|
||||
</button>
|
||||
</>
|
||||
)
|
||||
}
|
||||
case "NeedsMachineAuth":
|
||||
return (
|
||||
<div className="mb-4">
|
||||
This device is authorized, but needs approval from a network admin
|
||||
before it can connect to the network.
|
||||
</div>
|
||||
)
|
||||
default:
|
||||
return (
|
||||
<>
|
||||
<div className="mb-4">
|
||||
<p>
|
||||
You are connected! Access this device over Tailscale using the
|
||||
device name or IP address above.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
className={cx("button button-medium mb-4", {
|
||||
"button-red": data.AdvertiseExitNode,
|
||||
"button-blue": !data.AdvertiseExitNode,
|
||||
})}
|
||||
id="enabled"
|
||||
onClick={() =>
|
||||
updateNode({ AdvertiseExitNode: !data.AdvertiseExitNode })
|
||||
}
|
||||
>
|
||||
{data.AdvertiseExitNode
|
||||
? "Stop advertising Exit Node"
|
||||
: "Advertise as Exit Node"}
|
||||
</button>
|
||||
</>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export function Footer(props: { licensesURL: string; className?: string }) {
|
||||
return (
|
||||
<footer
|
||||
className={cx("container max-w-lg mx-auto text-center", props.className)}
|
||||
>
|
||||
<a
|
||||
className="text-xs text-gray-500 hover:text-gray-600"
|
||||
href={props.licensesURL}
|
||||
>
|
||||
Open Source Licenses
|
||||
</a>
|
||||
</footer>
|
||||
)
|
||||
}
|
||||
37
client/web/src/hooks/auth.ts
Normal file
37
client/web/src/hooks/auth.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { useCallback, useEffect, useState } from "react"
|
||||
import { apiFetch } from "src/api"
|
||||
|
||||
export type AuthResponse = {
|
||||
ok: boolean
|
||||
authUrl?: string
|
||||
}
|
||||
|
||||
// useAuth reports and refreshes Tailscale auth status
|
||||
// for the web client.
|
||||
export default function useAuth() {
|
||||
const [data, setData] = useState<AuthResponse>()
|
||||
const [loading, setLoading] = useState<boolean>(false)
|
||||
|
||||
const loadAuth = useCallback((wait?: boolean) => {
|
||||
const url = wait ? "/auth?wait=true" : "/auth"
|
||||
setLoading(true)
|
||||
return apiFetch(url, "GET")
|
||||
.then((r) => r.json())
|
||||
.then((d) => {
|
||||
setLoading(false)
|
||||
setData(d)
|
||||
})
|
||||
.catch((error) => {
|
||||
setLoading(false)
|
||||
console.error(error)
|
||||
})
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
loadAuth()
|
||||
}, [])
|
||||
|
||||
const waitOnAuth = useCallback(() => loadAuth(true), [])
|
||||
|
||||
return { data, loading, waitOnAuth }
|
||||
}
|
||||
117
client/web/src/hooks/node-data.ts
Normal file
117
client/web/src/hooks/node-data.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import { useCallback, useEffect, useState } from "react"
|
||||
import { apiFetch, setUnraidCsrfToken } from "src/api"
|
||||
|
||||
export type NodeData = {
|
||||
Profile: UserProfile
|
||||
Status: string
|
||||
DeviceName: string
|
||||
IP: string
|
||||
AdvertiseExitNode: boolean
|
||||
AdvertiseRoutes: string
|
||||
LicensesURL: string
|
||||
TUNMode: boolean
|
||||
IsSynology: boolean
|
||||
DSMVersion: number
|
||||
IsUnraid: boolean
|
||||
UnraidToken: string
|
||||
IPNVersion: string
|
||||
|
||||
DebugMode: "" | "login" | "full" // empty when not running in any debug mode
|
||||
}
|
||||
|
||||
export type UserProfile = {
|
||||
LoginName: string
|
||||
DisplayName: string
|
||||
ProfilePicURL: string
|
||||
}
|
||||
|
||||
export type NodeUpdate = {
|
||||
AdvertiseRoutes?: string
|
||||
AdvertiseExitNode?: boolean
|
||||
Reauthenticate?: boolean
|
||||
ForceLogout?: boolean
|
||||
}
|
||||
|
||||
// useNodeData returns basic data about the current node.
|
||||
export default function useNodeData() {
|
||||
const [data, setData] = useState<NodeData>()
|
||||
const [isPosting, setIsPosting] = useState<boolean>(false)
|
||||
|
||||
const refreshData = useCallback(
|
||||
() =>
|
||||
apiFetch("/data", "GET")
|
||||
.then((r) => r.json())
|
||||
.then((d: NodeData) => {
|
||||
setData(d)
|
||||
setUnraidCsrfToken(d.IsUnraid ? d.UnraidToken : undefined)
|
||||
})
|
||||
.catch((error) => console.error(error)),
|
||||
[setData]
|
||||
)
|
||||
|
||||
const updateNode = useCallback(
|
||||
(update: NodeUpdate) => {
|
||||
// The contents of this function are mostly copied over
|
||||
// from the legacy client's web.html file.
|
||||
// It makes all data updates through one API endpoint.
|
||||
// As we build out the web client in React,
|
||||
// this endpoint will eventually be deprecated.
|
||||
|
||||
if (isPosting || !data) {
|
||||
return
|
||||
}
|
||||
setIsPosting(true)
|
||||
|
||||
update = {
|
||||
...update,
|
||||
// Default to current data value for any unset fields.
|
||||
AdvertiseRoutes:
|
||||
update.AdvertiseRoutes !== undefined
|
||||
? update.AdvertiseRoutes
|
||||
: data.AdvertiseRoutes,
|
||||
AdvertiseExitNode:
|
||||
update.AdvertiseExitNode !== undefined
|
||||
? update.AdvertiseExitNode
|
||||
: data.AdvertiseExitNode,
|
||||
}
|
||||
|
||||
apiFetch("/data", "POST", update, { up: "true" })
|
||||
.then((r) => r.json())
|
||||
.then((r) => {
|
||||
setIsPosting(false)
|
||||
const err = r["error"]
|
||||
if (err) {
|
||||
throw new Error(err)
|
||||
}
|
||||
const url = r["url"]
|
||||
if (url) {
|
||||
window.open(url, "_blank")
|
||||
}
|
||||
refreshData()
|
||||
})
|
||||
.catch((err) => alert("Failed operation: " + err.message))
|
||||
},
|
||||
[data]
|
||||
)
|
||||
|
||||
useEffect(
|
||||
() => {
|
||||
// Initial data load.
|
||||
refreshData()
|
||||
|
||||
// Refresh on browser tab focus.
|
||||
const onVisibilityChange = () => {
|
||||
document.visibilityState === "visible" && refreshData()
|
||||
}
|
||||
window.addEventListener("visibilitychange", onVisibilityChange)
|
||||
return () => {
|
||||
// Cleanup browser tab listener.
|
||||
window.removeEventListener("visibilitychange", onVisibilityChange)
|
||||
}
|
||||
},
|
||||
// Run once.
|
||||
[]
|
||||
)
|
||||
|
||||
return { data, refreshData, updateNode, isPosting }
|
||||
}
|
||||
15
client/web/src/icons/connected-device.svg
Normal file
15
client/web/src/icons/connected-device.svg
Normal file
@@ -0,0 +1,15 @@
|
||||
<svg width="40" height="40" viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="40" height="40" rx="20" fill="#F7F5F4"/>
|
||||
<g clip-path="url(#clip0_13627_11903)">
|
||||
<path d="M26.6666 11.6667H13.3333C12.4128 11.6667 11.6666 12.4129 11.6666 13.3333V16.6667C11.6666 17.5871 12.4128 18.3333 13.3333 18.3333H26.6666C27.5871 18.3333 28.3333 17.5871 28.3333 16.6667V13.3333C28.3333 12.4129 27.5871 11.6667 26.6666 11.6667Z" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M26.6666 21.6667H13.3333C12.4128 21.6667 11.6666 22.4129 11.6666 23.3333V26.6667C11.6666 27.5871 12.4128 28.3333 13.3333 28.3333H26.6666C27.5871 28.3333 28.3333 27.5871 28.3333 26.6667V23.3333C28.3333 22.4129 27.5871 21.6667 26.6666 21.6667Z" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M15 15H15.01" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M15 25H15.01" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</g>
|
||||
<circle cx="34" cy="34" r="4.5" fill="#1EA672" stroke="white"/>
|
||||
<defs>
|
||||
<clipPath id="clip0_13627_11903">
|
||||
<rect width="20" height="20" fill="white" transform="translate(10 10)"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
18
client/web/src/icons/tailscale-icon.svg
Normal file
18
client/web/src/icons/tailscale-icon.svg
Normal file
@@ -0,0 +1,18 @@
|
||||
<svg width="26" height="26" viewBox="0 0 26 26" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_13627_11860)">
|
||||
<path opacity="0.2" d="M3.8696 6.77137C5.56662 6.77137 6.94233 5.39567 6.94233 3.69865C6.94233 2.00163 5.56662 0.625919 3.8696 0.625919C2.17258 0.625919 0.796875 2.00163 0.796875 3.69865C0.796875 5.39567 2.17258 6.77137 3.8696 6.77137Z" fill="black"/>
|
||||
<path d="M3.8696 15.9327C5.56662 15.9327 6.94233 14.5569 6.94233 12.8599C6.94233 11.1629 5.56662 9.7872 3.8696 9.7872C2.17258 9.7872 0.796875 11.1629 0.796875 12.8599C0.796875 14.5569 2.17258 15.9327 3.8696 15.9327Z" fill="black"/>
|
||||
<path opacity="0.2" d="M3.8696 25.2646C5.56662 25.2646 6.94233 23.8889 6.94233 22.1919C6.94233 20.4949 5.56662 19.1192 3.8696 19.1192C2.17258 19.1192 0.796875 20.4949 0.796875 22.1919C0.796875 23.8889 2.17258 25.2646 3.8696 25.2646Z" fill="black"/>
|
||||
<path d="M13.0879 15.9327C14.7849 15.9327 16.1606 14.5569 16.1606 12.8599C16.1606 11.1629 14.7849 9.7872 13.0879 9.7872C11.3908 9.7872 10.0151 11.1629 10.0151 12.8599C10.0151 14.5569 11.3908 15.9327 13.0879 15.9327Z" fill="black"/>
|
||||
<path d="M13.0879 25.2646C14.7849 25.2646 16.1606 23.8889 16.1606 22.1919C16.1606 20.4949 14.7849 19.1192 13.0879 19.1192C11.3908 19.1192 10.0151 20.4949 10.0151 22.1919C10.0151 23.8889 11.3908 25.2646 13.0879 25.2646Z" fill="black"/>
|
||||
<path opacity="0.2" d="M13.0879 6.77137C14.7849 6.77137 16.1606 5.39567 16.1606 3.69865C16.1606 2.00163 14.7849 0.625919 13.0879 0.625919C11.3908 0.625919 10.0151 2.00163 10.0151 3.69865C10.0151 5.39567 11.3908 6.77137 13.0879 6.77137Z" fill="black"/>
|
||||
<path opacity="0.2" d="M22.1919 6.77137C23.8889 6.77137 25.2646 5.39567 25.2646 3.69865C25.2646 2.00163 23.8889 0.625919 22.1919 0.625919C20.4948 0.625919 19.1191 2.00163 19.1191 3.69865C19.1191 5.39567 20.4948 6.77137 22.1919 6.77137Z" fill="black"/>
|
||||
<path d="M22.1919 15.9327C23.8889 15.9327 25.2646 14.5569 25.2646 12.8599C25.2646 11.1629 23.8889 9.7872 22.1919 9.7872C20.4948 9.7872 19.1191 11.1629 19.1191 12.8599C19.1191 14.5569 20.4948 15.9327 22.1919 15.9327Z" fill="black"/>
|
||||
<path opacity="0.2" d="M22.1919 25.2646C23.8889 25.2646 25.2646 23.8889 25.2646 22.1919C25.2646 20.4949 23.8889 19.1192 22.1919 19.1192C20.4948 19.1192 19.1191 20.4949 19.1191 22.1919C19.1191 23.8889 20.4948 25.2646 22.1919 25.2646Z" fill="black"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_13627_11860">
|
||||
<rect width="26" height="26" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.4 KiB |
20
client/web/src/icons/tailscale-logo.svg
Normal file
20
client/web/src/icons/tailscale-logo.svg
Normal file
@@ -0,0 +1,20 @@
|
||||
<svg width="121" height="22" viewBox="0 0 121 22" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<ellipse cx="2.69191" cy="10.7677" rx="2.69191" ry="2.69191" fill="#141414"/>
|
||||
<ellipse cx="10.7676" cy="10.7677" rx="2.69191" ry="2.69191" fill="#141414"/>
|
||||
<ellipse opacity="0.2" cx="2.69191" cy="18.8434" rx="2.69191" ry="2.69191" fill="#141414"/>
|
||||
<circle opacity="0.2" cx="18.8433" cy="18.8434" r="2.69191" fill="#141414"/>
|
||||
<ellipse cx="10.7676" cy="18.8434" rx="2.69191" ry="2.69191" fill="#141414"/>
|
||||
<circle cx="18.8433" cy="10.7677" r="2.69191" fill="#141414"/>
|
||||
<ellipse opacity="0.2" cx="2.69191" cy="2.69191" rx="2.69191" ry="2.69191" fill="#141414"/>
|
||||
<ellipse opacity="0.2" cx="10.7676" cy="2.69191" rx="2.69191" ry="2.69191" fill="#141414"/>
|
||||
<circle opacity="0.2" cx="18.8433" cy="2.69191" r="2.69191" fill="#141414"/>
|
||||
<path d="M37.8847 19.9603C38.6525 19.9603 39.2764 19.8883 40.0202 19.7443V16.9609C39.5643 17.1289 39.0605 17.1769 38.5806 17.1769C37.4048 17.1769 36.9729 16.601 36.9729 15.4973V9.83453H40.0202V7.05116H36.9729V2.92409H33.6137V7.05116H31.4302V9.83453H33.6137V15.8092C33.6137 18.4486 35.0054 19.9603 37.8847 19.9603Z" fill="#141414"/>
|
||||
<path d="M45.5064 19.9603C47.306 19.9603 48.5057 19.3604 49.1056 18.4246C49.1536 18.8325 49.2975 19.3844 49.4895 19.7203H52.5128C52.3448 19.1444 52.2249 18.2326 52.2249 17.6328V11.0583C52.2249 8.34687 50.2813 6.81121 46.994 6.81121C44.4986 6.81121 42.555 7.747 41.4753 9.1147L43.3949 11.0103C44.2587 10.0505 45.3624 9.5466 46.7061 9.5466C48.3377 9.5466 49.0576 10.0985 49.0576 10.9143C49.0576 11.6101 48.5777 12.09 45.9863 12.09C43.4908 12.09 40.9714 13.1218 40.9714 16.0011C40.9714 18.6645 42.891 19.9603 45.5064 19.9603ZM46.1782 17.4168C44.8825 17.4168 44.2827 16.8649 44.2827 15.8812C44.2827 15.0174 45.0025 14.4415 46.2022 14.4415C48.1218 14.4415 48.6497 14.3215 49.0576 13.9136V14.9454C49.0576 16.3131 47.9058 17.4168 46.1782 17.4168Z" fill="#141414"/>
|
||||
<path d="M54.4086 5.44352H57.9118V2.30023H54.4086V5.44352ZM54.4805 19.7203H57.8398V7.05116H54.4805V19.7203Z" fill="#141414"/>
|
||||
<path d="M60.287 19.7203H63.6463V2.68414H60.287V19.7203Z" fill="#141414"/>
|
||||
<path d="M70.6285 19.9603C74.3237 19.9603 76.2193 18.0167 76.2193 15.9771C76.2193 14.1296 75.2835 12.7619 72.2122 12.21C70.0527 11.8261 68.709 11.3462 68.709 10.6024C68.709 9.95451 69.4768 9.49861 70.7725 9.49861C71.9242 9.49861 72.884 9.88252 73.6038 10.7223L75.7394 8.92274C74.6596 7.57904 72.884 6.81121 70.7725 6.81121C67.5332 6.81121 65.5177 8.53883 65.5177 10.6503C65.5177 12.9538 67.6292 13.9856 69.9087 14.3935C71.8043 14.7294 72.86 15.0893 72.86 15.9052C72.86 16.601 72.1162 17.1769 70.7005 17.1769C69.3088 17.1769 68.2291 16.529 67.7252 15.5692L64.8938 16.9129C65.5897 18.6405 67.9651 19.9603 70.6285 19.9603Z" fill="#141414"/>
|
||||
<path d="M83.7294 19.9603C86.1288 19.9603 87.8564 19.0005 89.1521 16.841L86.4648 15.4733C85.9609 16.481 85.1451 17.1769 83.7294 17.1769C81.5939 17.1769 80.4421 15.4493 80.4421 13.3617C80.4421 11.2742 81.6658 9.59459 83.7294 9.59459C85.0251 9.59459 85.8889 10.2904 86.3928 11.3462L89.1042 9.90652C88.1924 7.91497 86.3928 6.81121 83.7294 6.81121C79.3384 6.81121 77.0829 10.0265 77.0829 13.3617C77.0829 16.9849 79.8183 19.9603 83.7294 19.9603Z" fill="#141414"/>
|
||||
<path d="M94.5031 19.9603C96.3027 19.9603 97.5025 19.3604 98.1023 18.4246C98.1503 18.8325 98.2943 19.3844 98.4862 19.7203H101.51C101.342 19.1444 101.222 18.2326 101.222 17.6328V11.0583C101.222 8.34687 99.2781 6.81121 95.9908 6.81121C93.4954 6.81121 91.5518 7.747 90.472 9.1147L92.3916 11.0103C93.2554 10.0505 94.3592 9.5466 95.7029 9.5466C97.3345 9.5466 98.0543 10.0985 98.0543 10.9143C98.0543 11.6101 97.5744 12.09 94.983 12.09C92.4876 12.09 89.9682 13.1218 89.9682 16.0011C89.9682 18.6645 91.8877 19.9603 94.5031 19.9603ZM95.175 17.4168C93.8793 17.4168 93.2794 16.8649 93.2794 15.8812C93.2794 15.0174 93.9992 14.4415 95.199 14.4415C97.1185 14.4415 97.6464 14.3215 98.0543 13.9136V14.9454C98.0543 16.3131 96.9026 17.4168 95.175 17.4168Z" fill="#141414"/>
|
||||
<path d="M103.196 19.7203H106.555V2.68414H103.196V19.7203Z" fill="#141414"/>
|
||||
<path d="M114.617 19.9603C117.089 19.9603 119.08 18.9765 120.184 17.2249L117.641 15.5932C116.969 16.649 116.081 17.2249 114.617 17.2249C112.962 17.2249 111.762 16.3131 111.45 14.5375H121V13.3617C121 10.0265 118.96 6.81121 114.593 6.81121C110.442 6.81121 108.187 10.0505 108.187 13.3857C108.187 18.1367 111.762 19.9603 114.617 19.9603ZM111.57 11.8981C112.098 10.2904 113.202 9.5466 114.665 9.5466C116.321 9.5466 117.329 10.5304 117.665 11.8981H111.57Z" fill="#141414"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.4 KiB |
130
client/web/src/index.css
Normal file
130
client/web/src/index.css
Normal file
@@ -0,0 +1,130 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/**
|
||||
* Non-Tailwind styles begin here.
|
||||
*/
|
||||
|
||||
.bg-gray-0 {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgba(250, 249, 248, var(--tw-bg-opacity));
|
||||
}
|
||||
|
||||
.bg-gray-50 {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgba(249, 247, 246, var(--tw-bg-opacity));
|
||||
}
|
||||
|
||||
html {
|
||||
letter-spacing: -0.015em;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
.link {
|
||||
--text-opacity: 1;
|
||||
color: #4b70cc;
|
||||
color: rgba(75, 112, 204, var(--text-opacity));
|
||||
}
|
||||
|
||||
.link:hover,
|
||||
.link:active {
|
||||
--text-opacity: 1;
|
||||
color: #19224a;
|
||||
color: rgba(25, 34, 74, var(--text-opacity));
|
||||
}
|
||||
|
||||
.link-underline {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.link-underline:hover,
|
||||
.link-underline:active {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.link-muted {
|
||||
/* same as text-gray-500 */
|
||||
--tw-text-opacity: 1;
|
||||
color: rgba(112, 110, 109, var(--tw-text-opacity));
|
||||
}
|
||||
|
||||
.link-muted:hover,
|
||||
.link-muted:active {
|
||||
/* same as text-gray-500 */
|
||||
--tw-text-opacity: 1;
|
||||
color: rgba(68, 67, 66, var(--tw-text-opacity));
|
||||
}
|
||||
|
||||
.button {
|
||||
font-weight: 500;
|
||||
padding-top: 0.45rem;
|
||||
padding-bottom: 0.45rem;
|
||||
padding-left: 1rem;
|
||||
padding-right: 1rem;
|
||||
border-radius: 0.375rem;
|
||||
border-width: 1px;
|
||||
border-color: transparent;
|
||||
transition-property: background-color, border-color, color, box-shadow;
|
||||
transition-duration: 120ms;
|
||||
box-shadow: 0 1px 1px rgba(0, 0, 0, 0.04);
|
||||
min-width: 80px;
|
||||
}
|
||||
|
||||
.button:focus {
|
||||
outline: 0;
|
||||
box-shadow: 0 0 0 3px rgba(66, 153, 225, 0.5);
|
||||
}
|
||||
|
||||
.button:disabled {
|
||||
cursor: not-allowed;
|
||||
-webkit-user-select: none;
|
||||
-ms-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.button-blue {
|
||||
--bg-opacity: 1;
|
||||
background-color: #4b70cc;
|
||||
background-color: rgba(75, 112, 204, var(--bg-opacity));
|
||||
--border-opacity: 1;
|
||||
border-color: #4b70cc;
|
||||
border-color: rgba(75, 112, 204, var(--border-opacity));
|
||||
--text-opacity: 1;
|
||||
color: #fff;
|
||||
color: rgba(255, 255, 255, var(--text-opacity));
|
||||
}
|
||||
|
||||
.button-blue:enabled:hover {
|
||||
--bg-opacity: 1;
|
||||
background-color: #3f5db3;
|
||||
background-color: rgba(63, 93, 179, var(--bg-opacity));
|
||||
--border-opacity: 1;
|
||||
border-color: #3f5db3;
|
||||
border-color: rgba(63, 93, 179, var(--border-opacity));
|
||||
}
|
||||
|
||||
.button-blue:disabled {
|
||||
--text-opacity: 1;
|
||||
color: #cedefd;
|
||||
color: rgba(206, 222, 253, var(--text-opacity));
|
||||
--bg-opacity: 1;
|
||||
background-color: #6c94ec;
|
||||
background-color: rgba(108, 148, 236, var(--bg-opacity));
|
||||
--border-opacity: 1;
|
||||
border-color: #6c94ec;
|
||||
border-color: rgba(108, 148, 236, var(--border-opacity));
|
||||
}
|
||||
|
||||
.button-red {
|
||||
background-color: #d04841;
|
||||
border-color: #d04841;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.button-red:enabled:hover {
|
||||
background-color: #b22d30;
|
||||
border-color: #b22d30;
|
||||
}
|
||||
20
client/web/src/index.tsx
Normal file
20
client/web/src/index.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import React from "react"
|
||||
import { createRoot } from "react-dom/client"
|
||||
import App from "src/components/app"
|
||||
|
||||
declare var window: any
|
||||
// This is used to determine if the react client is built.
|
||||
window.Tailscale = true
|
||||
|
||||
const rootEl = document.createElement("div")
|
||||
rootEl.id = "app-root"
|
||||
rootEl.classList.add("relative", "z-0")
|
||||
document.body.append(rootEl)
|
||||
|
||||
const root = createRoot(rootEl)
|
||||
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
)
|
||||
78
client/web/synology.go
Normal file
78
client/web/synology.go
Normal file
@@ -0,0 +1,78 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
// synology.go contains handlers and logic, such as authentication,
|
||||
// that is specific to running the web client on Synology.
|
||||
|
||||
package web
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
||||
"tailscale.com/util/groupmember"
|
||||
)
|
||||
|
||||
// authorizeSynology authenticates the logged-in Synology user and verifies
|
||||
// that they are authorized to use the web client.
|
||||
// It reports true if the request is authorized to continue, and false otherwise.
|
||||
// authorizeSynology manages writing out any relevant authorization errors to the
|
||||
// ResponseWriter itself.
|
||||
func authorizeSynology(w http.ResponseWriter, r *http.Request) (ok bool) {
|
||||
if synoTokenRedirect(w, r) {
|
||||
return false
|
||||
}
|
||||
|
||||
// authenticate the Synology user
|
||||
cmd := exec.Command("/usr/syno/synoman/webman/modules/authenticate.cgi")
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("auth: %v: %s", err, out), http.StatusUnauthorized)
|
||||
return false
|
||||
}
|
||||
user := strings.TrimSpace(string(out))
|
||||
|
||||
// check if the user is in the administrators group
|
||||
isAdmin, err := groupmember.IsMemberOfGroup("administrators", user)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusForbidden)
|
||||
return false
|
||||
}
|
||||
if !isAdmin {
|
||||
http.Error(w, "not a member of administrators group", http.StatusForbidden)
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
func synoTokenRedirect(w http.ResponseWriter, r *http.Request) bool {
|
||||
if r.Header.Get("X-Syno-Token") != "" {
|
||||
return false
|
||||
}
|
||||
if r.URL.Query().Get("SynoToken") != "" {
|
||||
return false
|
||||
}
|
||||
if r.Method == "POST" && r.FormValue("SynoToken") != "" {
|
||||
return false
|
||||
}
|
||||
// We need a SynoToken for authenticate.cgi.
|
||||
// So we tell the client to get one.
|
||||
_, _ = fmt.Fprint(w, synoTokenRedirectHTML)
|
||||
return true
|
||||
}
|
||||
|
||||
const synoTokenRedirectHTML = `<html>
|
||||
Redirecting with session token...
|
||||
<script>
|
||||
fetch("/webman/login.cgi")
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
u = new URL(window.location)
|
||||
u.searchParams.set("SynoToken", data.SynoToken)
|
||||
document.location = u
|
||||
})
|
||||
</script>
|
||||
`
|
||||
12
client/web/tailwind.config.js
Normal file
12
client/web/tailwind.config.js
Normal file
@@ -0,0 +1,12 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
content: [
|
||||
"./index.html",
|
||||
"./src/**/*.{js,ts,jsx,tsx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
|
||||
17
client/web/tsconfig.json
Normal file
17
client/web/tsconfig.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"target": "ES2017",
|
||||
"module": "ES2020",
|
||||
"strict": true,
|
||||
"sourceMap": true,
|
||||
"isolatedModules": true,
|
||||
"moduleResolution": "node",
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"jsx": "react",
|
||||
"types": ["vite-plugin-svgr/client", "vite/client"]
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
69
client/web/vite.config.ts
Normal file
69
client/web/vite.config.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
/// <reference types="vitest" />
|
||||
import { createLogger, defineConfig } from "vite"
|
||||
import rewrite from "vite-plugin-rewrite-all"
|
||||
import svgr from "vite-plugin-svgr"
|
||||
import paths from "vite-tsconfig-paths"
|
||||
|
||||
// Use a custom logger that filters out Vite's logging of server URLs, since
|
||||
// they are an attractive nuisance (we run a proxy in front of Vite, and the
|
||||
// tailscale web client should be accessed through that).
|
||||
// Unfortunately there's no option to disable this logging, so the best we can
|
||||
// do it to ignore calls from a specific function.
|
||||
const filteringLogger = createLogger(undefined, { allowClearScreen: false })
|
||||
const originalInfoLog = filteringLogger.info
|
||||
filteringLogger.info = (...args) => {
|
||||
if (new Error("ignored").stack?.includes("printServerUrls")) {
|
||||
return
|
||||
}
|
||||
originalInfoLog.apply(filteringLogger, args)
|
||||
}
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
base: "./",
|
||||
plugins: [
|
||||
paths(),
|
||||
svgr(),
|
||||
// By default, the Vite dev server doesn't handle dots
|
||||
// in path names and treats them as static files.
|
||||
// This plugin changes Vite's routing logic to fix this.
|
||||
// See: https://github.com/vitejs/vite/issues/2415
|
||||
rewrite(),
|
||||
],
|
||||
build: {
|
||||
outDir: "build",
|
||||
sourcemap: false,
|
||||
},
|
||||
esbuild: {
|
||||
logOverride: {
|
||||
// Silence a warning about `this` being undefined in ESM when at the
|
||||
// top-level. The way JSX is transpiled causes this to happen, but it
|
||||
// isn't a problem.
|
||||
// See: https://github.com/vitejs/vite/issues/8644
|
||||
"this-is-undefined-in-esm": "silent",
|
||||
},
|
||||
},
|
||||
server: {
|
||||
// This needs to be 127.0.0.1 instead of localhost, because of how our
|
||||
// Go proxy connects to it.
|
||||
host: "127.0.0.1",
|
||||
// If you change the port, be sure to update the proxy in adminhttp.go too.
|
||||
port: 4000,
|
||||
// Don't proxy the WebSocket connection used for live reloading by running
|
||||
// it on a separate port.
|
||||
hmr: {
|
||||
protocol: "ws",
|
||||
port: 4001,
|
||||
},
|
||||
},
|
||||
test: {
|
||||
exclude: ["**/node_modules/**", "**/dist/**"],
|
||||
testTimeout: 20000,
|
||||
environment: "jsdom",
|
||||
deps: {
|
||||
inline: ["date-fns", /\.wasm\?url$/],
|
||||
},
|
||||
},
|
||||
clearScreen: false,
|
||||
customLogger: filteringLogger,
|
||||
})
|
||||
844
client/web/web.go
Normal file
844
client/web/web.go
Normal file
@@ -0,0 +1,844 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
// Package web provides the Tailscale client for web.
|
||||
package web
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/csrf"
|
||||
"tailscale.com/client/tailscale"
|
||||
"tailscale.com/client/tailscale/apitype"
|
||||
"tailscale.com/envknob"
|
||||
"tailscale.com/ipn"
|
||||
"tailscale.com/ipn/ipnstate"
|
||||
"tailscale.com/licenses"
|
||||
"tailscale.com/net/netutil"
|
||||
"tailscale.com/tailcfg"
|
||||
"tailscale.com/util/httpm"
|
||||
"tailscale.com/version/distro"
|
||||
)
|
||||
|
||||
// Server is the backend server for a Tailscale web client.
|
||||
type Server struct {
|
||||
lc *tailscale.LocalClient
|
||||
timeNow func() time.Time
|
||||
|
||||
devMode bool
|
||||
tsDebugMode string
|
||||
|
||||
cgiMode bool
|
||||
pathPrefix string
|
||||
|
||||
assetsHandler http.Handler // serves frontend assets
|
||||
apiHandler http.Handler // serves api endpoints; csrf-protected
|
||||
|
||||
// browserSessions is an in-memory cache of browser sessions for the
|
||||
// full management web client, which is only accessible over Tailscale.
|
||||
//
|
||||
// Users obtain a valid browser session by connecting to the web client
|
||||
// over Tailscale and verifying their identity by authenticating on the
|
||||
// control server.
|
||||
//
|
||||
// browserSessions get reset on every Server restart.
|
||||
//
|
||||
// The map provides a lookup of the session by cookie value
|
||||
// (browserSession.ID => browserSession).
|
||||
browserSessions sync.Map
|
||||
}
|
||||
|
||||
const (
|
||||
sessionCookieName = "TS-Web-Session"
|
||||
sessionCookieExpiry = time.Hour * 24 * 30 // 30 days
|
||||
)
|
||||
|
||||
var (
|
||||
exitNodeRouteV4 = netip.MustParsePrefix("0.0.0.0/0")
|
||||
exitNodeRouteV6 = netip.MustParsePrefix("::/0")
|
||||
)
|
||||
|
||||
// browserSession holds data about a user's browser session
|
||||
// on the full management web client.
|
||||
type browserSession struct {
|
||||
// ID is the unique identifier for the session.
|
||||
// It is passed in the user's "TS-Web-Session" browser cookie.
|
||||
ID string
|
||||
SrcNode tailcfg.NodeID
|
||||
SrcUser tailcfg.UserID
|
||||
AuthID string // from tailcfg.WebClientAuthResponse
|
||||
AuthURL string // from tailcfg.WebClientAuthResponse
|
||||
Created time.Time
|
||||
Authenticated bool
|
||||
}
|
||||
|
||||
// isAuthorized reports true if the given session is authorized
|
||||
// to be used by its associated user to access the full management
|
||||
// web client.
|
||||
//
|
||||
// isAuthorized is true only when s.Authenticated is true (i.e.
|
||||
// the user has authenticated the session) and the session is not
|
||||
// expired.
|
||||
// 2023-10-05: Sessions expire by default 30 days after creation.
|
||||
func (s *browserSession) isAuthorized() bool {
|
||||
switch {
|
||||
case s == nil:
|
||||
return false
|
||||
case !s.Authenticated:
|
||||
return false // awaiting auth
|
||||
case s.isExpired():
|
||||
return false // expired
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// isExpired reports true if s is expired.
|
||||
// 2023-10-05: Sessions expire by default 30 days after creation.
|
||||
func (s *browserSession) isExpired() bool {
|
||||
return !s.Created.IsZero() && time.Now().After(s.expires()) // TODO: use Server.timeNow field
|
||||
}
|
||||
|
||||
// expires reports when the given session expires.
|
||||
func (s *browserSession) expires() time.Time {
|
||||
return s.Created.Add(sessionCookieExpiry)
|
||||
}
|
||||
|
||||
// ServerOpts contains options for constructing a new Server.
|
||||
type ServerOpts struct {
|
||||
DevMode bool
|
||||
|
||||
// CGIMode indicates if the server is running as a CGI script.
|
||||
CGIMode bool
|
||||
|
||||
// PathPrefix is the URL prefix added to requests by CGI or reverse proxy.
|
||||
PathPrefix string
|
||||
|
||||
// LocalClient is the tailscale.LocalClient to use for this web server.
|
||||
// If nil, a new one will be created.
|
||||
LocalClient *tailscale.LocalClient
|
||||
|
||||
// TimeNow optionally provides a time function.
|
||||
// time.Now is used as default.
|
||||
TimeNow func() time.Time
|
||||
}
|
||||
|
||||
// NewServer constructs a new Tailscale web client server.
|
||||
func NewServer(opts ServerOpts) (s *Server, cleanup func()) {
|
||||
if opts.LocalClient == nil {
|
||||
opts.LocalClient = &tailscale.LocalClient{}
|
||||
}
|
||||
s = &Server{
|
||||
devMode: opts.DevMode,
|
||||
lc: opts.LocalClient,
|
||||
pathPrefix: opts.PathPrefix,
|
||||
timeNow: opts.TimeNow,
|
||||
}
|
||||
if s.timeNow == nil {
|
||||
s.timeNow = time.Now
|
||||
}
|
||||
s.tsDebugMode = s.debugMode()
|
||||
s.assetsHandler, cleanup = assetsHandler(opts.DevMode)
|
||||
|
||||
// Create handler for "/api" requests with CSRF protection.
|
||||
// We don't require secure cookies, since the web client is regularly used
|
||||
// on network appliances that are served on local non-https URLs.
|
||||
// The client is secured by limiting the interface it listens on,
|
||||
// or by authenticating requests before they reach the web client.
|
||||
csrfProtect := csrf.Protect(s.csrfKey(), csrf.Secure(false))
|
||||
if s.tsDebugMode == "login" {
|
||||
// For the login client, we don't serve the full web client API,
|
||||
// only the login endpoints.
|
||||
s.apiHandler = csrfProtect(http.HandlerFunc(s.serveLoginAPI))
|
||||
s.lc.IncrementCounter(context.Background(), "web_login_client_initialization", 1)
|
||||
} else {
|
||||
s.apiHandler = csrfProtect(http.HandlerFunc(s.serveAPI))
|
||||
s.lc.IncrementCounter(context.Background(), "web_client_initialization", 1)
|
||||
}
|
||||
|
||||
return s, cleanup
|
||||
}
|
||||
|
||||
// debugMode returns the debug mode the web client is being run in.
|
||||
// The empty string is returned in the case that this instance is
|
||||
// not running in any debug mode.
|
||||
func (s *Server) debugMode() string {
|
||||
if !s.devMode {
|
||||
return "" // debug modes only available in dev
|
||||
}
|
||||
switch mode := os.Getenv("TS_DEBUG_WEB_CLIENT_MODE"); mode {
|
||||
case "login", "full": // valid debug modes
|
||||
return mode
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// ServeHTTP processes all requests for the Tailscale web client.
|
||||
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
handler := s.serve
|
||||
|
||||
// if path prefix is defined, strip it from requests.
|
||||
if s.pathPrefix != "" {
|
||||
handler = enforcePrefix(s.pathPrefix, handler)
|
||||
}
|
||||
|
||||
handler(w, r)
|
||||
}
|
||||
|
||||
func (s *Server) serve(w http.ResponseWriter, r *http.Request) {
|
||||
if ok := s.authorizeRequest(w, r); !ok {
|
||||
return
|
||||
}
|
||||
if strings.HasPrefix(r.URL.Path, "/api/") {
|
||||
// Pass API requests through to the API handler.
|
||||
s.apiHandler.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
if !s.devMode {
|
||||
s.lc.IncrementCounter(r.Context(), "web_client_page_load", 1)
|
||||
}
|
||||
s.assetsHandler.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
// authorizeRequest reports whether the request from the web client
|
||||
// is authorized to be completed.
|
||||
// It reports true if the request is authorized, and false otherwise.
|
||||
// authorizeRequest manages writing out any relevant authorization
|
||||
// errors to the ResponseWriter itself.
|
||||
func (s *Server) authorizeRequest(w http.ResponseWriter, r *http.Request) (ok bool) {
|
||||
if s.tsDebugMode == "full" { // client using tailscale auth
|
||||
_, err := s.lc.WhoIs(r.Context(), r.RemoteAddr)
|
||||
switch {
|
||||
case err != nil:
|
||||
// All requests must be made over tailscale.
|
||||
http.Error(w, "must access over tailscale", http.StatusUnauthorized)
|
||||
return false
|
||||
case r.URL.Path == "/api/data" && r.Method == httpm.GET:
|
||||
// Readonly endpoint allowed without browser session.
|
||||
return true
|
||||
case r.URL.Path == "/api/auth":
|
||||
// Endpoint for browser to request auth allowed without browser session.
|
||||
return true
|
||||
case strings.HasPrefix(r.URL.Path, "/api/"):
|
||||
// All other /api/ endpoints require a valid browser session.
|
||||
//
|
||||
// TODO(sonia): s.getTailscaleBrowserSession calls whois again,
|
||||
// should try and use the above call instead of running another
|
||||
// localapi request.
|
||||
session, _, err := s.getTailscaleBrowserSession(r)
|
||||
if err != nil || !session.isAuthorized() {
|
||||
http.Error(w, "no valid session", http.StatusUnauthorized)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
default:
|
||||
// No additional auth on non-api (assets, index.html, etc).
|
||||
return true
|
||||
}
|
||||
}
|
||||
// Client using system-specific auth.
|
||||
d := distro.Get()
|
||||
switch {
|
||||
case strings.HasPrefix(r.URL.Path, "/assets/") && r.Method == httpm.GET:
|
||||
// Don't require authorization for static assets.
|
||||
return true
|
||||
case d == distro.Synology:
|
||||
return authorizeSynology(w, r)
|
||||
case d == distro.QNAP:
|
||||
return authorizeQNAP(w, r)
|
||||
default:
|
||||
return true // no additional auth for this distro
|
||||
}
|
||||
}
|
||||
|
||||
// serveLoginAPI serves requests for the web login client.
|
||||
// It should only be called by Server.ServeHTTP, via Server.apiHandler,
|
||||
// which protects the handler using gorilla csrf.
|
||||
func (s *Server) serveLoginAPI(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("X-CSRF-Token", csrf.Token(r))
|
||||
if r.URL.Path != "/api/data" { // only endpoint allowed for login client
|
||||
http.Error(w, "invalid endpoint", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
switch r.Method {
|
||||
case httpm.GET:
|
||||
// TODO(soniaappasamy): we may want a minimal node data response here
|
||||
s.serveGetNodeData(w, r)
|
||||
case httpm.POST:
|
||||
// TODO(soniaappasamy): implement
|
||||
default:
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
var (
|
||||
errNoSession = errors.New("no-browser-session")
|
||||
errNotUsingTailscale = errors.New("not-using-tailscale")
|
||||
errTaggedSource = errors.New("tagged-source")
|
||||
errNotOwner = errors.New("not-owner")
|
||||
)
|
||||
|
||||
// getTailscaleBrowserSession retrieves the browser session associated with
|
||||
// the request, if one exists.
|
||||
//
|
||||
// An error is returned in any of the following cases:
|
||||
//
|
||||
// - (errNotUsingTailscale) The request was not made over tailscale.
|
||||
//
|
||||
// - (errNoSession) The request does not have a session.
|
||||
//
|
||||
// - (errTaggedSource) The source is a tagged node. Users must use their
|
||||
// own user-owned devices to manage other nodes' web clients.
|
||||
//
|
||||
// - (errNotOwner) The source is not the owner of this client (if the
|
||||
// client is user-owned). Only the owner is allowed to manage the
|
||||
// node via the web client.
|
||||
//
|
||||
// If no error is returned, the browserSession is always non-nil.
|
||||
// getTailscaleBrowserSession does not check whether the session has been
|
||||
// authorized by the user. Callers can use browserSession.isAuthorized.
|
||||
//
|
||||
// The WhoIsResponse is always populated, with a non-nil Node and UserProfile,
|
||||
// unless getTailscaleBrowserSession reports errNotUsingTailscale.
|
||||
func (s *Server) getTailscaleBrowserSession(r *http.Request) (*browserSession, *apitype.WhoIsResponse, error) {
|
||||
whoIs, err := s.lc.WhoIs(r.Context(), r.RemoteAddr)
|
||||
switch {
|
||||
case err != nil:
|
||||
return nil, nil, errNotUsingTailscale
|
||||
case whoIs.Node.IsTagged():
|
||||
return nil, whoIs, errTaggedSource
|
||||
}
|
||||
srcNode := whoIs.Node.ID
|
||||
srcUser := whoIs.UserProfile.ID
|
||||
|
||||
status, err := s.lc.StatusWithoutPeers(r.Context())
|
||||
switch {
|
||||
case err != nil:
|
||||
return nil, whoIs, err
|
||||
case status.Self == nil:
|
||||
return nil, whoIs, errors.New("missing self node in tailscale status")
|
||||
case !status.Self.IsTagged() && status.Self.UserID != srcUser:
|
||||
return nil, whoIs, errNotOwner
|
||||
}
|
||||
|
||||
cookie, err := r.Cookie(sessionCookieName)
|
||||
if errors.Is(err, http.ErrNoCookie) {
|
||||
return nil, whoIs, errNoSession
|
||||
} else if err != nil {
|
||||
return nil, whoIs, err
|
||||
}
|
||||
v, ok := s.browserSessions.Load(cookie.Value)
|
||||
if !ok {
|
||||
return nil, whoIs, errNoSession
|
||||
}
|
||||
session := v.(*browserSession)
|
||||
if session.SrcNode != srcNode || session.SrcUser != srcUser {
|
||||
// In this case the browser cookie is associated with another tailscale node.
|
||||
// Maybe the source browser's machine was logged out and then back in as a different node.
|
||||
// Return errNoSession because there is no session for this user.
|
||||
return nil, whoIs, errNoSession
|
||||
} else if session.isExpired() {
|
||||
// Session expired, remove from session map and return errNoSession.
|
||||
s.browserSessions.Delete(session.ID)
|
||||
return nil, whoIs, errNoSession
|
||||
}
|
||||
return session, whoIs, nil
|
||||
}
|
||||
|
||||
type authResponse struct {
|
||||
OK bool `json:"ok"` // true when user has valid auth session
|
||||
AuthURL string `json:"authUrl,omitempty"` // filled when user has control auth action to take
|
||||
}
|
||||
|
||||
func (s *Server) serveTailscaleAuth(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != httpm.GET {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
var resp authResponse
|
||||
|
||||
session, whois, err := s.getTailscaleBrowserSession(r)
|
||||
switch {
|
||||
case err != nil && !errors.Is(err, errNoSession):
|
||||
http.Error(w, err.Error(), http.StatusUnauthorized)
|
||||
return
|
||||
case session == nil:
|
||||
// Create a new session.
|
||||
d, err := s.getOrAwaitAuth(r.Context(), "", whois.Node.ID)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
sid, err := s.newSessionID()
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
session := &browserSession{
|
||||
ID: sid,
|
||||
SrcNode: whois.Node.ID,
|
||||
SrcUser: whois.UserProfile.ID,
|
||||
AuthID: d.ID,
|
||||
AuthURL: d.URL,
|
||||
Created: s.timeNow(),
|
||||
}
|
||||
s.browserSessions.Store(sid, session)
|
||||
// Set the cookie on browser.
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: sessionCookieName,
|
||||
Value: sid,
|
||||
Raw: sid,
|
||||
Path: "/",
|
||||
Expires: session.expires(),
|
||||
})
|
||||
resp = authResponse{OK: false, AuthURL: d.URL}
|
||||
case !session.isAuthorized():
|
||||
if r.URL.Query().Get("wait") == "true" {
|
||||
// Client requested we block until user completes auth.
|
||||
d, err := s.getOrAwaitAuth(r.Context(), session.AuthID, whois.Node.ID)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusUnauthorized)
|
||||
// Clean up the session. Doing this on any error from control
|
||||
// server to avoid the user getting stuck with a bad session
|
||||
// cookie.
|
||||
s.browserSessions.Delete(session.ID)
|
||||
return
|
||||
}
|
||||
if d.Complete {
|
||||
session.Authenticated = d.Complete
|
||||
s.browserSessions.Store(session.ID, session)
|
||||
}
|
||||
}
|
||||
if session.isAuthorized() {
|
||||
resp = authResponse{OK: true}
|
||||
} else {
|
||||
resp = authResponse{OK: false, AuthURL: session.AuthURL}
|
||||
}
|
||||
default:
|
||||
resp = authResponse{OK: true}
|
||||
}
|
||||
|
||||
if err := json.NewEncoder(w).Encode(resp); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
}
|
||||
|
||||
func (s *Server) newSessionID() (string, error) {
|
||||
raw := make([]byte, 16)
|
||||
for i := 0; i < 5; i++ {
|
||||
if _, err := rand.Read(raw); err != nil {
|
||||
return "", err
|
||||
}
|
||||
cookie := "ts-web-" + base64.RawURLEncoding.EncodeToString(raw)
|
||||
if _, ok := s.browserSessions.Load(cookie); !ok {
|
||||
return cookie, nil
|
||||
}
|
||||
}
|
||||
return "", errors.New("too many collisions generating new session; please refresh page")
|
||||
}
|
||||
|
||||
// getOrAwaitAuth connects to the control server for user auth,
|
||||
// with the following behavior:
|
||||
//
|
||||
// 1. If authID is provided empty, a new auth URL is created on the control
|
||||
// server and reported back here, which can then be used to redirect the
|
||||
// user on the frontend.
|
||||
// 2. If authID is provided non-empty, the connection to control blocks until
|
||||
// the user has completed authenticating the associated auth URL,
|
||||
// or until ctx is canceled.
|
||||
func (s *Server) getOrAwaitAuth(ctx context.Context, authID string, src tailcfg.NodeID) (*tailcfg.WebClientAuthResponse, error) {
|
||||
type data struct {
|
||||
ID string
|
||||
Src tailcfg.NodeID
|
||||
}
|
||||
var b bytes.Buffer
|
||||
if err := json.NewEncoder(&b).Encode(data{ID: authID, Src: src}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
url := "http://" + apitype.LocalAPIHost + "/localapi/v0/debug-web-client"
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", url, &b)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, err := s.lc.DoLocalRequest(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("failed request: %s", body)
|
||||
}
|
||||
var authResp *tailcfg.WebClientAuthResponse
|
||||
if err := json.Unmarshal(body, &authResp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return authResp, nil
|
||||
}
|
||||
|
||||
// serveAPI serves requests for the web client api.
|
||||
// It should only be called by Server.ServeHTTP, via Server.apiHandler,
|
||||
// which protects the handler using gorilla csrf.
|
||||
func (s *Server) serveAPI(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("X-CSRF-Token", csrf.Token(r))
|
||||
path := strings.TrimPrefix(r.URL.Path, "/api")
|
||||
switch {
|
||||
case path == "/auth":
|
||||
if s.tsDebugMode == "full" { // behind debug flag
|
||||
s.serveTailscaleAuth(w, r)
|
||||
return
|
||||
}
|
||||
case path == "/data":
|
||||
switch r.Method {
|
||||
case httpm.GET:
|
||||
s.serveGetNodeData(w, r)
|
||||
case httpm.POST:
|
||||
s.servePostNodeUpdate(w, r)
|
||||
default:
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
return
|
||||
case strings.HasPrefix(path, "/local/"):
|
||||
s.proxyRequestToLocalAPI(w, r)
|
||||
return
|
||||
}
|
||||
http.Error(w, "invalid endpoint", http.StatusNotFound)
|
||||
}
|
||||
|
||||
type nodeData struct {
|
||||
Profile tailcfg.UserProfile
|
||||
Status string
|
||||
DeviceName string
|
||||
IP string
|
||||
AdvertiseExitNode bool
|
||||
AdvertiseRoutes string
|
||||
LicensesURL string
|
||||
TUNMode bool
|
||||
IsSynology bool
|
||||
DSMVersion int // 6 or 7, if IsSynology=true
|
||||
IsUnraid bool
|
||||
UnraidToken string
|
||||
IPNVersion string
|
||||
DebugMode string // empty when not running in any debug mode
|
||||
}
|
||||
|
||||
func (s *Server) serveGetNodeData(w http.ResponseWriter, r *http.Request) {
|
||||
st, err := s.lc.Status(r.Context())
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
prefs, err := s.lc.GetPrefs(r.Context())
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
profile := st.User[st.Self.UserID]
|
||||
deviceName := strings.Split(st.Self.DNSName, ".")[0]
|
||||
versionShort := strings.Split(st.Version, "-")[0]
|
||||
data := &nodeData{
|
||||
Profile: profile,
|
||||
Status: st.BackendState,
|
||||
DeviceName: deviceName,
|
||||
LicensesURL: licenses.LicensesURL(),
|
||||
TUNMode: st.TUN,
|
||||
IsSynology: distro.Get() == distro.Synology || envknob.Bool("TS_FAKE_SYNOLOGY"),
|
||||
DSMVersion: distro.DSMVersion(),
|
||||
IsUnraid: distro.Get() == distro.Unraid,
|
||||
UnraidToken: os.Getenv("UNRAID_CSRF_TOKEN"),
|
||||
IPNVersion: versionShort,
|
||||
DebugMode: s.tsDebugMode,
|
||||
}
|
||||
for _, r := range prefs.AdvertiseRoutes {
|
||||
if r == exitNodeRouteV4 || r == exitNodeRouteV6 {
|
||||
data.AdvertiseExitNode = true
|
||||
} else {
|
||||
if data.AdvertiseRoutes != "" {
|
||||
data.AdvertiseRoutes += ","
|
||||
}
|
||||
data.AdvertiseRoutes += r.String()
|
||||
}
|
||||
}
|
||||
if len(st.TailscaleIPs) != 0 {
|
||||
data.IP = st.TailscaleIPs[0].String()
|
||||
}
|
||||
if err := json.NewEncoder(w).Encode(*data); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
}
|
||||
|
||||
type nodeUpdate struct {
|
||||
AdvertiseRoutes string
|
||||
AdvertiseExitNode bool
|
||||
Reauthenticate bool
|
||||
ForceLogout bool
|
||||
}
|
||||
|
||||
func (s *Server) servePostNodeUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
defer r.Body.Close()
|
||||
|
||||
st, err := s.lc.Status(r.Context())
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
var postData nodeUpdate
|
||||
type mi map[string]any
|
||||
if err := json.NewDecoder(r.Body).Decode(&postData); err != nil {
|
||||
w.WriteHeader(400)
|
||||
json.NewEncoder(w).Encode(mi{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
prefs, err := s.lc.GetPrefs(r.Context())
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
isCurrentlyExitNode := slices.Contains(prefs.AdvertiseRoutes, exitNodeRouteV4) || slices.Contains(prefs.AdvertiseRoutes, exitNodeRouteV6)
|
||||
|
||||
if postData.AdvertiseExitNode != isCurrentlyExitNode {
|
||||
if postData.AdvertiseExitNode {
|
||||
s.lc.IncrementCounter(r.Context(), "web_client_advertise_exitnode_enable", 1)
|
||||
} else {
|
||||
s.lc.IncrementCounter(r.Context(), "web_client_advertise_exitnode_disable", 1)
|
||||
}
|
||||
}
|
||||
|
||||
routes, err := netutil.CalcAdvertiseRoutes(postData.AdvertiseRoutes, postData.AdvertiseExitNode)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
json.NewEncoder(w).Encode(mi{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
mp := &ipn.MaskedPrefs{
|
||||
AdvertiseRoutesSet: true,
|
||||
WantRunningSet: true,
|
||||
}
|
||||
mp.Prefs.WantRunning = true
|
||||
mp.Prefs.AdvertiseRoutes = routes
|
||||
log.Printf("Doing edit: %v", mp.Pretty())
|
||||
|
||||
if _, err := s.lc.EditPrefs(r.Context(), mp); err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
json.NewEncoder(w).Encode(mi{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
var reauth, logout bool
|
||||
if postData.Reauthenticate {
|
||||
reauth = true
|
||||
}
|
||||
if postData.ForceLogout {
|
||||
logout = true
|
||||
}
|
||||
log.Printf("tailscaleUp(reauth=%v, logout=%v) ...", reauth, logout)
|
||||
url, err := s.tailscaleUp(r.Context(), st, postData)
|
||||
log.Printf("tailscaleUp = (URL %v, %v)", url != "", err)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
json.NewEncoder(w).Encode(mi{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if url != "" {
|
||||
json.NewEncoder(w).Encode(mi{"url": url})
|
||||
} else {
|
||||
io.WriteString(w, "{}")
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) tailscaleUp(ctx context.Context, st *ipnstate.Status, postData nodeUpdate) (authURL string, retErr error) {
|
||||
if postData.ForceLogout {
|
||||
if err := s.lc.Logout(ctx); err != nil {
|
||||
return "", fmt.Errorf("Logout error: %w", err)
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
origAuthURL := st.AuthURL
|
||||
isRunning := st.BackendState == ipn.Running.String()
|
||||
|
||||
forceReauth := postData.Reauthenticate
|
||||
if !forceReauth {
|
||||
if origAuthURL != "" {
|
||||
return origAuthURL, nil
|
||||
}
|
||||
if isRunning {
|
||||
return "", nil
|
||||
}
|
||||
}
|
||||
|
||||
// printAuthURL reports whether we should print out the
|
||||
// provided auth URL from an IPN notify.
|
||||
printAuthURL := func(url string) bool {
|
||||
return url != origAuthURL
|
||||
}
|
||||
|
||||
watchCtx, cancelWatch := context.WithCancel(ctx)
|
||||
defer cancelWatch()
|
||||
watcher, err := s.lc.WatchIPNBus(watchCtx, 0)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer watcher.Close()
|
||||
|
||||
go func() {
|
||||
if !isRunning {
|
||||
s.lc.Start(ctx, ipn.Options{})
|
||||
}
|
||||
if forceReauth {
|
||||
s.lc.StartLoginInteractive(ctx)
|
||||
}
|
||||
}()
|
||||
|
||||
for {
|
||||
n, err := watcher.Next()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if n.ErrMessage != nil {
|
||||
msg := *n.ErrMessage
|
||||
return "", fmt.Errorf("backend error: %v", msg)
|
||||
}
|
||||
if url := n.BrowseToURL; url != nil && printAuthURL(*url) {
|
||||
return *url, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// proxyRequestToLocalAPI proxies the web API request to the localapi.
|
||||
//
|
||||
// The web API request path is expected to exactly match a localapi path,
|
||||
// with prefix /api/local/ rather than /localapi/.
|
||||
//
|
||||
// If the localapi path is not included in localapiAllowlist,
|
||||
// the request is rejected.
|
||||
func (s *Server) proxyRequestToLocalAPI(w http.ResponseWriter, r *http.Request) {
|
||||
path := strings.TrimPrefix(r.URL.Path, "/api/local")
|
||||
if r.URL.Path == path { // missing prefix
|
||||
http.Error(w, "invalid request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if !slices.Contains(localapiAllowlist, path) {
|
||||
http.Error(w, fmt.Sprintf("%s not allowed from localapi proxy", path), http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
localAPIURL := "http://" + apitype.LocalAPIHost + "/localapi" + path
|
||||
req, err := http.NewRequestWithContext(r.Context(), r.Method, localAPIURL, r.Body)
|
||||
if err != nil {
|
||||
http.Error(w, "failed to construct request", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Make request to tailscaled localapi.
|
||||
resp, err := s.lc.DoLocalRequest(req)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), resp.StatusCode)
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Send response back to web frontend.
|
||||
w.Header().Set("Content-Type", resp.Header.Get("Content-Type"))
|
||||
w.WriteHeader(resp.StatusCode)
|
||||
if _, err := io.Copy(w, resp.Body); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// localapiAllowlist is an allowlist of localapi endpoints the
|
||||
// web client is allowed to proxy to the client's localapi.
|
||||
//
|
||||
// Rather than exposing all localapi endpoints over the proxy,
|
||||
// this limits to just the ones actually used from the web
|
||||
// client frontend.
|
||||
//
|
||||
// TODO(sonia,will): Shouldn't expand this beyond the existing
|
||||
// localapi endpoints until the larger web client auth story
|
||||
// is worked out (tailscale/corp#14335).
|
||||
var localapiAllowlist = []string{
|
||||
"/v0/logout",
|
||||
}
|
||||
|
||||
// csrfKey returns a key that can be used for CSRF protection.
|
||||
// If an error occurs during key creation, the error is logged and the active process terminated.
|
||||
// If the server is running in CGI mode, the key is cached to disk and reused between requests.
|
||||
// If an error occurs during key storage, the error is logged and the active process terminated.
|
||||
func (s *Server) csrfKey() []byte {
|
||||
csrfFile := filepath.Join(os.TempDir(), "tailscale-web-csrf.key")
|
||||
|
||||
// if running in CGI mode, try to read from disk, but ignore errors
|
||||
if s.cgiMode {
|
||||
key, _ := os.ReadFile(csrfFile)
|
||||
if len(key) == 32 {
|
||||
return key
|
||||
}
|
||||
}
|
||||
|
||||
// create a new key
|
||||
key := make([]byte, 32)
|
||||
if _, err := rand.Read(key); err != nil {
|
||||
log.Fatalf("error generating CSRF key: %v", err)
|
||||
}
|
||||
|
||||
// if running in CGI mode, try to write the newly created key to disk, and exit if it fails.
|
||||
if s.cgiMode {
|
||||
if err := os.WriteFile(csrfFile, key, 0600); err != nil {
|
||||
log.Fatalf("unable to store CSRF key: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
return key
|
||||
}
|
||||
|
||||
// enforcePrefix returns a HandlerFunc that enforces a given path prefix is used in requests,
|
||||
// then strips it before invoking h.
|
||||
// Unlike http.StripPrefix, it does not return a 404 if the prefix is not present.
|
||||
// Instead, it returns a redirect to the prefix path.
|
||||
func enforcePrefix(prefix string, h http.HandlerFunc) http.HandlerFunc {
|
||||
if prefix == "" {
|
||||
return h
|
||||
}
|
||||
|
||||
// ensure that prefix always has both a leading and trailing slash so
|
||||
// that relative links for JS and CSS assets work correctly.
|
||||
if !strings.HasPrefix(prefix, "/") {
|
||||
prefix = "/" + prefix
|
||||
}
|
||||
if !strings.HasSuffix(prefix, "/") {
|
||||
prefix += "/"
|
||||
}
|
||||
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if !strings.HasPrefix(r.URL.Path, prefix) {
|
||||
http.Redirect(w, r, prefix, http.StatusFound)
|
||||
return
|
||||
}
|
||||
prefix = strings.TrimSuffix(prefix, "/")
|
||||
http.StripPrefix(prefix, h).ServeHTTP(w, r)
|
||||
}
|
||||
}
|
||||
694
client/web/web_test.go
Normal file
694
client/web/web_test.go
Normal file
@@ -0,0 +1,694 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package web
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/go-cmp/cmp"
|
||||
"tailscale.com/client/tailscale"
|
||||
"tailscale.com/client/tailscale/apitype"
|
||||
"tailscale.com/ipn/ipnstate"
|
||||
"tailscale.com/net/memnet"
|
||||
"tailscale.com/tailcfg"
|
||||
"tailscale.com/types/views"
|
||||
"tailscale.com/util/httpm"
|
||||
)
|
||||
|
||||
func TestQnapAuthnURL(t *testing.T) {
|
||||
query := url.Values{
|
||||
"qtoken": []string{"token"},
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
in string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "localhost http",
|
||||
in: "http://localhost:8088/",
|
||||
want: "http://localhost:8088/cgi-bin/authLogin.cgi?qtoken=token",
|
||||
},
|
||||
{
|
||||
name: "localhost https",
|
||||
in: "https://localhost:5000/",
|
||||
want: "https://localhost:5000/cgi-bin/authLogin.cgi?qtoken=token",
|
||||
},
|
||||
{
|
||||
name: "IP http",
|
||||
in: "http://10.1.20.4:80/",
|
||||
want: "http://10.1.20.4:80/cgi-bin/authLogin.cgi?qtoken=token",
|
||||
},
|
||||
{
|
||||
name: "IP6 https",
|
||||
in: "https://[ff7d:0:1:2::1]/",
|
||||
want: "https://[ff7d:0:1:2::1]/cgi-bin/authLogin.cgi?qtoken=token",
|
||||
},
|
||||
{
|
||||
name: "hostname https",
|
||||
in: "https://qnap.example.com/",
|
||||
want: "https://qnap.example.com/cgi-bin/authLogin.cgi?qtoken=token",
|
||||
},
|
||||
{
|
||||
name: "invalid URL",
|
||||
in: "This is not a URL, it is a really really really really really really really really really really really really long string to exercise the URL truncation code in the error path.",
|
||||
want: "http://localhost/cgi-bin/authLogin.cgi?qtoken=token",
|
||||
},
|
||||
{
|
||||
name: "err != nil",
|
||||
in: "http://192.168.0.%31/",
|
||||
want: "http://localhost/cgi-bin/authLogin.cgi?qtoken=token",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
u := qnapAuthnURL(tt.in, query)
|
||||
if u != tt.want {
|
||||
t.Errorf("expected url: %q, got: %q", tt.want, u)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestServeAPI tests the web client api's handling of
|
||||
// 1. invalid endpoint errors
|
||||
// 2. localapi proxy allowlist
|
||||
func TestServeAPI(t *testing.T) {
|
||||
lal := memnet.Listen("local-tailscaled.sock:80")
|
||||
defer lal.Close()
|
||||
// Serve dummy localapi. Just returns "success".
|
||||
localapi := &http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
fmt.Fprintf(w, "success")
|
||||
})}
|
||||
defer localapi.Close()
|
||||
|
||||
go localapi.Serve(lal)
|
||||
s := &Server{lc: &tailscale.LocalClient{Dial: lal.Dial}}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
reqPath string
|
||||
wantResp string
|
||||
wantStatus int
|
||||
}{{
|
||||
name: "invalid_endpoint",
|
||||
reqPath: "/not-an-endpoint",
|
||||
wantResp: "invalid endpoint",
|
||||
wantStatus: http.StatusNotFound,
|
||||
}, {
|
||||
name: "not_in_localapi_allowlist",
|
||||
reqPath: "/local/v0/not-allowlisted",
|
||||
wantResp: "/v0/not-allowlisted not allowed from localapi proxy",
|
||||
wantStatus: http.StatusForbidden,
|
||||
}, {
|
||||
name: "in_localapi_allowlist",
|
||||
reqPath: "/local/v0/logout",
|
||||
wantResp: "success", // Successfully allowed to hit localapi.
|
||||
wantStatus: http.StatusOK,
|
||||
}}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
r := httptest.NewRequest("POST", "/api"+tt.reqPath, nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
s.serveAPI(w, r)
|
||||
res := w.Result()
|
||||
defer res.Body.Close()
|
||||
if gotStatus := res.StatusCode; tt.wantStatus != gotStatus {
|
||||
t.Errorf("wrong status; want=%v, got=%v", tt.wantStatus, gotStatus)
|
||||
}
|
||||
body, err := io.ReadAll(res.Body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
gotResp := strings.TrimSuffix(string(body), "\n") // trim trailing newline
|
||||
if tt.wantResp != gotResp {
|
||||
t.Errorf("wrong response; want=%q, got=%q", tt.wantResp, gotResp)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetTailscaleBrowserSession(t *testing.T) {
|
||||
userA := &tailcfg.UserProfile{ID: tailcfg.UserID(1)}
|
||||
userB := &tailcfg.UserProfile{ID: tailcfg.UserID(2)}
|
||||
|
||||
userANodeIP := "100.100.100.101"
|
||||
userBNodeIP := "100.100.100.102"
|
||||
taggedNodeIP := "100.100.100.103"
|
||||
|
||||
var selfNode *ipnstate.PeerStatus
|
||||
tags := views.SliceOf([]string{"tag:server"})
|
||||
tailnetNodes := map[string]*apitype.WhoIsResponse{
|
||||
userANodeIP: {
|
||||
Node: &tailcfg.Node{ID: 1},
|
||||
UserProfile: userA,
|
||||
},
|
||||
userBNodeIP: {
|
||||
Node: &tailcfg.Node{ID: 2},
|
||||
UserProfile: userB,
|
||||
},
|
||||
taggedNodeIP: {
|
||||
Node: &tailcfg.Node{ID: 3, Tags: tags.AsSlice()},
|
||||
},
|
||||
}
|
||||
|
||||
lal := memnet.Listen("local-tailscaled.sock:80")
|
||||
defer lal.Close()
|
||||
localapi := mockLocalAPI(t, tailnetNodes, func() *ipnstate.PeerStatus { return selfNode })
|
||||
defer localapi.Close()
|
||||
go localapi.Serve(lal)
|
||||
|
||||
s := &Server{lc: &tailscale.LocalClient{Dial: lal.Dial}}
|
||||
|
||||
// Add some browser sessions to cache state.
|
||||
userASession := &browserSession{
|
||||
ID: "cookie1",
|
||||
SrcNode: 1,
|
||||
SrcUser: userA.ID,
|
||||
Created: time.Now(),
|
||||
Authenticated: false, // not yet authenticated
|
||||
}
|
||||
userBSession := &browserSession{
|
||||
ID: "cookie2",
|
||||
SrcNode: 2,
|
||||
SrcUser: userB.ID,
|
||||
Created: time.Now().Add(-2 * sessionCookieExpiry),
|
||||
Authenticated: true, // expired
|
||||
}
|
||||
userASessionAuthorized := &browserSession{
|
||||
ID: "cookie3",
|
||||
SrcNode: 1,
|
||||
SrcUser: userA.ID,
|
||||
Created: time.Now(),
|
||||
Authenticated: true, // authenticated and not expired
|
||||
}
|
||||
s.browserSessions.Store(userASession.ID, userASession)
|
||||
s.browserSessions.Store(userBSession.ID, userBSession)
|
||||
s.browserSessions.Store(userASessionAuthorized.ID, userASessionAuthorized)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
selfNode *ipnstate.PeerStatus
|
||||
remoteAddr string
|
||||
cookie string
|
||||
|
||||
wantSession *browserSession
|
||||
wantError error
|
||||
wantIsAuthorized bool // response from session.isAuthorized
|
||||
}{
|
||||
{
|
||||
name: "not-connected-over-tailscale",
|
||||
selfNode: &ipnstate.PeerStatus{ID: "self", UserID: userA.ID},
|
||||
remoteAddr: "77.77.77.77",
|
||||
wantSession: nil,
|
||||
wantError: errNotUsingTailscale,
|
||||
},
|
||||
{
|
||||
name: "no-session-user-self-node",
|
||||
selfNode: &ipnstate.PeerStatus{ID: "self", UserID: userA.ID},
|
||||
remoteAddr: userANodeIP,
|
||||
cookie: "not-a-cookie",
|
||||
wantSession: nil,
|
||||
wantError: errNoSession,
|
||||
},
|
||||
{
|
||||
name: "no-session-tagged-self-node",
|
||||
selfNode: &ipnstate.PeerStatus{ID: "self", Tags: &tags},
|
||||
remoteAddr: userANodeIP,
|
||||
wantSession: nil,
|
||||
wantError: errNoSession,
|
||||
},
|
||||
{
|
||||
name: "not-owner",
|
||||
selfNode: &ipnstate.PeerStatus{ID: "self", UserID: userA.ID},
|
||||
remoteAddr: userBNodeIP,
|
||||
wantSession: nil,
|
||||
wantError: errNotOwner,
|
||||
},
|
||||
{
|
||||
name: "tagged-source",
|
||||
selfNode: &ipnstate.PeerStatus{ID: "self", UserID: userA.ID},
|
||||
remoteAddr: taggedNodeIP,
|
||||
wantSession: nil,
|
||||
wantError: errTaggedSource,
|
||||
},
|
||||
{
|
||||
name: "has-session",
|
||||
selfNode: &ipnstate.PeerStatus{ID: "self", UserID: userA.ID},
|
||||
remoteAddr: userANodeIP,
|
||||
cookie: userASession.ID,
|
||||
wantSession: userASession,
|
||||
wantError: nil,
|
||||
},
|
||||
{
|
||||
name: "has-authorized-session",
|
||||
selfNode: &ipnstate.PeerStatus{ID: "self", UserID: userA.ID},
|
||||
remoteAddr: userANodeIP,
|
||||
cookie: userASessionAuthorized.ID,
|
||||
wantSession: userASessionAuthorized,
|
||||
wantError: nil,
|
||||
wantIsAuthorized: true,
|
||||
},
|
||||
{
|
||||
name: "session-associated-with-different-source",
|
||||
selfNode: &ipnstate.PeerStatus{ID: "self", UserID: userB.ID},
|
||||
remoteAddr: userBNodeIP,
|
||||
cookie: userASession.ID,
|
||||
wantSession: nil,
|
||||
wantError: errNoSession,
|
||||
},
|
||||
{
|
||||
name: "session-expired",
|
||||
selfNode: &ipnstate.PeerStatus{ID: "self", UserID: userB.ID},
|
||||
remoteAddr: userBNodeIP,
|
||||
cookie: userBSession.ID,
|
||||
wantSession: nil,
|
||||
wantError: errNoSession,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
selfNode = tt.selfNode
|
||||
r := &http.Request{RemoteAddr: tt.remoteAddr, Header: http.Header{}}
|
||||
if tt.cookie != "" {
|
||||
r.AddCookie(&http.Cookie{Name: sessionCookieName, Value: tt.cookie})
|
||||
}
|
||||
session, _, err := s.getTailscaleBrowserSession(r)
|
||||
if !errors.Is(err, tt.wantError) {
|
||||
t.Errorf("wrong error; want=%v, got=%v", tt.wantError, err)
|
||||
}
|
||||
if diff := cmp.Diff(session, tt.wantSession); diff != "" {
|
||||
t.Errorf("wrong session; (-got+want):%v", diff)
|
||||
}
|
||||
if gotIsAuthorized := session.isAuthorized(); gotIsAuthorized != tt.wantIsAuthorized {
|
||||
t.Errorf("wrong isAuthorized; want=%v, got=%v", tt.wantIsAuthorized, gotIsAuthorized)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestAuthorizeRequest tests the s.authorizeRequest function.
|
||||
// 2023-10-18: These tests currently cover tailscale auth mode (not platform auth).
|
||||
func TestAuthorizeRequest(t *testing.T) {
|
||||
// Create self and remoteNode owned by same user.
|
||||
// See TestGetTailscaleBrowserSession for tests of
|
||||
// browser sessions w/ different users.
|
||||
user := &tailcfg.UserProfile{ID: tailcfg.UserID(1)}
|
||||
self := &ipnstate.PeerStatus{ID: "self", UserID: user.ID}
|
||||
remoteNode := &apitype.WhoIsResponse{Node: &tailcfg.Node{StableID: "node"}, UserProfile: user}
|
||||
remoteIP := "100.100.100.101"
|
||||
|
||||
lal := memnet.Listen("local-tailscaled.sock:80")
|
||||
defer lal.Close()
|
||||
localapi := mockLocalAPI(t,
|
||||
map[string]*apitype.WhoIsResponse{remoteIP: remoteNode},
|
||||
func() *ipnstate.PeerStatus { return self },
|
||||
)
|
||||
defer localapi.Close()
|
||||
go localapi.Serve(lal)
|
||||
|
||||
s := &Server{
|
||||
lc: &tailscale.LocalClient{Dial: lal.Dial},
|
||||
tsDebugMode: "full",
|
||||
}
|
||||
validCookie := "ts-cookie"
|
||||
s.browserSessions.Store(validCookie, &browserSession{
|
||||
ID: validCookie,
|
||||
SrcNode: remoteNode.Node.ID,
|
||||
SrcUser: user.ID,
|
||||
Created: time.Now(),
|
||||
Authenticated: true,
|
||||
})
|
||||
|
||||
tests := []struct {
|
||||
reqPath string
|
||||
reqMethod string
|
||||
|
||||
wantOkNotOverTailscale bool // simulates req over public internet
|
||||
wantOkWithoutSession bool // simulates req over TS without valid browser session
|
||||
wantOkWithSession bool // simulates req over TS with valid browser session
|
||||
}{{
|
||||
reqPath: "/api/data",
|
||||
reqMethod: httpm.GET,
|
||||
wantOkNotOverTailscale: false,
|
||||
wantOkWithoutSession: true,
|
||||
wantOkWithSession: true,
|
||||
}, {
|
||||
reqPath: "/api/data",
|
||||
reqMethod: httpm.POST,
|
||||
wantOkNotOverTailscale: false,
|
||||
wantOkWithoutSession: false,
|
||||
wantOkWithSession: true,
|
||||
}, {
|
||||
reqPath: "/api/auth",
|
||||
reqMethod: httpm.GET,
|
||||
wantOkNotOverTailscale: false,
|
||||
wantOkWithoutSession: true,
|
||||
wantOkWithSession: true,
|
||||
}, {
|
||||
reqPath: "/api/somethingelse",
|
||||
reqMethod: httpm.GET,
|
||||
wantOkNotOverTailscale: false,
|
||||
wantOkWithoutSession: false,
|
||||
wantOkWithSession: true,
|
||||
}, {
|
||||
reqPath: "/assets/styles.css",
|
||||
wantOkNotOverTailscale: false,
|
||||
wantOkWithoutSession: true,
|
||||
wantOkWithSession: true,
|
||||
}}
|
||||
for _, tt := range tests {
|
||||
t.Run(fmt.Sprintf("%s-%s", tt.reqMethod, tt.reqPath), func(t *testing.T) {
|
||||
doAuthorize := func(remoteAddr string, cookie string) bool {
|
||||
r := httptest.NewRequest(tt.reqMethod, tt.reqPath, nil)
|
||||
r.RemoteAddr = remoteAddr
|
||||
if cookie != "" {
|
||||
r.AddCookie(&http.Cookie{Name: sessionCookieName, Value: cookie})
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
return s.authorizeRequest(w, r)
|
||||
}
|
||||
// Do request from non-Tailscale IP.
|
||||
if gotOk := doAuthorize("123.456.789.999", ""); gotOk != tt.wantOkNotOverTailscale {
|
||||
t.Errorf("wantOkNotOverTailscale; want=%v, got=%v", tt.wantOkNotOverTailscale, gotOk)
|
||||
}
|
||||
// Do request from Tailscale IP w/o associated session.
|
||||
if gotOk := doAuthorize(remoteIP, ""); gotOk != tt.wantOkWithoutSession {
|
||||
t.Errorf("wantOkWithoutSession; want=%v, got=%v", tt.wantOkWithoutSession, gotOk)
|
||||
}
|
||||
// Do request from Tailscale IP w/ associated session.
|
||||
if gotOk := doAuthorize(remoteIP, validCookie); gotOk != tt.wantOkWithSession {
|
||||
t.Errorf("wantOkWithSession; want=%v, got=%v", tt.wantOkWithSession, gotOk)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeTailscaleAuth(t *testing.T) {
|
||||
user := &tailcfg.UserProfile{ID: tailcfg.UserID(1)}
|
||||
self := &ipnstate.PeerStatus{ID: "self", UserID: user.ID}
|
||||
remoteNode := &apitype.WhoIsResponse{Node: &tailcfg.Node{ID: 1}, UserProfile: user}
|
||||
remoteIP := "100.100.100.101"
|
||||
|
||||
lal := memnet.Listen("local-tailscaled.sock:80")
|
||||
defer lal.Close()
|
||||
localapi := mockLocalAPI(t,
|
||||
map[string]*apitype.WhoIsResponse{remoteIP: remoteNode},
|
||||
func() *ipnstate.PeerStatus { return self },
|
||||
)
|
||||
defer localapi.Close()
|
||||
go localapi.Serve(lal)
|
||||
|
||||
timeNow := time.Now()
|
||||
oneHourAgo := timeNow.Add(-time.Hour)
|
||||
sixtyDaysAgo := timeNow.Add(-sessionCookieExpiry * 2)
|
||||
|
||||
s := &Server{
|
||||
lc: &tailscale.LocalClient{Dial: lal.Dial},
|
||||
tsDebugMode: "full",
|
||||
timeNow: func() time.Time { return timeNow },
|
||||
}
|
||||
|
||||
successCookie := "ts-cookie-success"
|
||||
s.browserSessions.Store(successCookie, &browserSession{
|
||||
ID: successCookie,
|
||||
SrcNode: remoteNode.Node.ID,
|
||||
SrcUser: user.ID,
|
||||
Created: oneHourAgo,
|
||||
AuthID: testAuthPathSuccess,
|
||||
AuthURL: testControlURL + testAuthPathSuccess,
|
||||
})
|
||||
failureCookie := "ts-cookie-failure"
|
||||
s.browserSessions.Store(failureCookie, &browserSession{
|
||||
ID: failureCookie,
|
||||
SrcNode: remoteNode.Node.ID,
|
||||
SrcUser: user.ID,
|
||||
Created: oneHourAgo,
|
||||
AuthID: testAuthPathError,
|
||||
AuthURL: testControlURL + testAuthPathError,
|
||||
})
|
||||
expiredCookie := "ts-cookie-expired"
|
||||
s.browserSessions.Store(expiredCookie, &browserSession{
|
||||
ID: expiredCookie,
|
||||
SrcNode: remoteNode.Node.ID,
|
||||
SrcUser: user.ID,
|
||||
Created: sixtyDaysAgo,
|
||||
AuthID: "/a/old-auth-url",
|
||||
AuthURL: testControlURL + "/a/old-auth-url",
|
||||
})
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
cookie string
|
||||
query string
|
||||
wantStatus int
|
||||
wantResp *authResponse
|
||||
wantNewCookie bool // new cookie generated
|
||||
wantSession *browserSession // session associated w/ cookie at end of request
|
||||
}{
|
||||
{
|
||||
name: "new-session-created",
|
||||
wantStatus: http.StatusOK,
|
||||
wantResp: &authResponse{OK: false, AuthURL: testControlURL + testAuthPath},
|
||||
wantNewCookie: true,
|
||||
wantSession: &browserSession{
|
||||
ID: "GENERATED_ID", // gets swapped for newly created ID by test
|
||||
SrcNode: remoteNode.Node.ID,
|
||||
SrcUser: user.ID,
|
||||
Created: timeNow,
|
||||
AuthID: testAuthPath,
|
||||
AuthURL: testControlURL + testAuthPath,
|
||||
Authenticated: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "query-existing-incomplete-session",
|
||||
cookie: successCookie,
|
||||
wantStatus: http.StatusOK,
|
||||
wantResp: &authResponse{OK: false, AuthURL: testControlURL + testAuthPathSuccess},
|
||||
wantSession: &browserSession{
|
||||
ID: successCookie,
|
||||
SrcNode: remoteNode.Node.ID,
|
||||
SrcUser: user.ID,
|
||||
Created: oneHourAgo,
|
||||
AuthID: testAuthPathSuccess,
|
||||
AuthURL: testControlURL + testAuthPathSuccess,
|
||||
Authenticated: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "transition-to-successful-session",
|
||||
cookie: successCookie,
|
||||
// query "wait" indicates the FE wants to make
|
||||
// local api call to wait until session completed.
|
||||
query: "wait=true",
|
||||
wantStatus: http.StatusOK,
|
||||
wantResp: &authResponse{OK: true},
|
||||
wantSession: &browserSession{
|
||||
ID: successCookie,
|
||||
SrcNode: remoteNode.Node.ID,
|
||||
SrcUser: user.ID,
|
||||
Created: oneHourAgo,
|
||||
AuthID: testAuthPathSuccess,
|
||||
AuthURL: testControlURL + testAuthPathSuccess,
|
||||
Authenticated: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "query-existing-complete-session",
|
||||
cookie: successCookie,
|
||||
wantStatus: http.StatusOK,
|
||||
wantResp: &authResponse{OK: true},
|
||||
wantSession: &browserSession{
|
||||
ID: successCookie,
|
||||
SrcNode: remoteNode.Node.ID,
|
||||
SrcUser: user.ID,
|
||||
Created: oneHourAgo,
|
||||
AuthID: testAuthPathSuccess,
|
||||
AuthURL: testControlURL + testAuthPathSuccess,
|
||||
Authenticated: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "transition-to-failed-session",
|
||||
cookie: failureCookie,
|
||||
query: "wait=true",
|
||||
wantStatus: http.StatusUnauthorized,
|
||||
wantResp: nil,
|
||||
wantSession: nil, // session deleted
|
||||
},
|
||||
{
|
||||
name: "failed-session-cleaned-up",
|
||||
cookie: failureCookie,
|
||||
wantStatus: http.StatusOK,
|
||||
wantResp: &authResponse{OK: false, AuthURL: testControlURL + testAuthPath},
|
||||
wantNewCookie: true,
|
||||
wantSession: &browserSession{
|
||||
ID: "GENERATED_ID",
|
||||
SrcNode: remoteNode.Node.ID,
|
||||
SrcUser: user.ID,
|
||||
Created: timeNow,
|
||||
AuthID: testAuthPath,
|
||||
AuthURL: testControlURL + testAuthPath,
|
||||
Authenticated: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "expired-cookie-gets-new-session",
|
||||
cookie: expiredCookie,
|
||||
wantStatus: http.StatusOK,
|
||||
wantResp: &authResponse{OK: false, AuthURL: testControlURL + testAuthPath},
|
||||
wantNewCookie: true,
|
||||
wantSession: &browserSession{
|
||||
ID: "GENERATED_ID",
|
||||
SrcNode: remoteNode.Node.ID,
|
||||
SrcUser: user.ID,
|
||||
Created: timeNow,
|
||||
AuthID: testAuthPath,
|
||||
AuthURL: testControlURL + testAuthPath,
|
||||
Authenticated: false,
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
r := httptest.NewRequest("GET", "/api/auth", nil)
|
||||
r.URL.RawQuery = tt.query
|
||||
r.RemoteAddr = remoteIP
|
||||
r.AddCookie(&http.Cookie{Name: sessionCookieName, Value: tt.cookie})
|
||||
w := httptest.NewRecorder()
|
||||
s.serveTailscaleAuth(w, r)
|
||||
res := w.Result()
|
||||
defer res.Body.Close()
|
||||
|
||||
// Validate response status/data.
|
||||
if gotStatus := res.StatusCode; tt.wantStatus != gotStatus {
|
||||
t.Errorf("wrong status; want=%v, got=%v", tt.wantStatus, gotStatus)
|
||||
}
|
||||
var gotResp *authResponse
|
||||
if res.StatusCode == http.StatusOK {
|
||||
body, err := io.ReadAll(res.Body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := json.Unmarshal(body, &gotResp); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if diff := cmp.Diff(gotResp, tt.wantResp); diff != "" {
|
||||
t.Errorf("wrong response; (-got+want):%v", diff)
|
||||
}
|
||||
// Validate cookie creation.
|
||||
sessionID := tt.cookie
|
||||
var gotCookie bool
|
||||
for _, c := range w.Result().Cookies() {
|
||||
if c.Name == sessionCookieName {
|
||||
gotCookie = true
|
||||
sessionID = c.Value
|
||||
break
|
||||
}
|
||||
}
|
||||
if gotCookie != tt.wantNewCookie {
|
||||
t.Errorf("wantNewCookie wrong; want=%v, got=%v", tt.wantNewCookie, gotCookie)
|
||||
}
|
||||
// Validate browser session contents.
|
||||
var gotSesson *browserSession
|
||||
if s, ok := s.browserSessions.Load(sessionID); ok {
|
||||
gotSesson = s.(*browserSession)
|
||||
}
|
||||
if tt.wantSession != nil && tt.wantSession.ID == "GENERATED_ID" {
|
||||
// If requested, swap in the generated session ID before
|
||||
// comparing got/want.
|
||||
tt.wantSession.ID = sessionID
|
||||
}
|
||||
if diff := cmp.Diff(gotSesson, tt.wantSession); diff != "" {
|
||||
t.Errorf("wrong session; (-got+want):%v", diff)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
var (
|
||||
testControlURL = "http://localhost:8080"
|
||||
testAuthPath = "/a/12345"
|
||||
testAuthPathSuccess = "/a/will-succeed"
|
||||
testAuthPathError = "/a/will-error"
|
||||
)
|
||||
|
||||
// mockLocalAPI constructs a test localapi handler that can be used
|
||||
// to simulate localapi responses without a functioning tailnet.
|
||||
//
|
||||
// self accepts a function that resolves to a self node status,
|
||||
// so that tests may swap out the /localapi/v0/status response
|
||||
// as desired.
|
||||
func mockLocalAPI(t *testing.T, whoIs map[string]*apitype.WhoIsResponse, self func() *ipnstate.PeerStatus) *http.Server {
|
||||
return &http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/localapi/v0/whois":
|
||||
addr := r.URL.Query().Get("addr")
|
||||
if addr == "" {
|
||||
t.Fatalf("/whois call missing \"addr\" query")
|
||||
}
|
||||
if node := whoIs[addr]; node != nil {
|
||||
if err := json.NewEncoder(w).Encode(&node); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
return
|
||||
}
|
||||
http.Error(w, "not a node", http.StatusUnauthorized)
|
||||
return
|
||||
case "/localapi/v0/status":
|
||||
status := ipnstate.Status{Self: self()}
|
||||
if err := json.NewEncoder(w).Encode(status); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
return
|
||||
case "/localapi/v0/debug-web-client": // used by TestServeTailscaleAuth
|
||||
type reqData struct {
|
||||
ID string
|
||||
Src tailcfg.NodeID
|
||||
}
|
||||
var data reqData
|
||||
if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
|
||||
http.Error(w, "invalid JSON body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if data.Src == 0 {
|
||||
http.Error(w, "missing Src node", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
var resp *tailcfg.WebClientAuthResponse
|
||||
if data.ID == "" {
|
||||
resp = &tailcfg.WebClientAuthResponse{ID: testAuthPath, URL: testControlURL + testAuthPath}
|
||||
} else if data.ID == testAuthPathSuccess {
|
||||
resp = &tailcfg.WebClientAuthResponse{Complete: true}
|
||||
} else if data.ID == testAuthPathError {
|
||||
http.Error(w, "authenticated as wrong user", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
if err := json.NewEncoder(w).Encode(resp); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
return
|
||||
default:
|
||||
t.Fatalf("unhandled localapi test endpoint %q, add to localapi handler func in test", r.URL.Path)
|
||||
}
|
||||
})}
|
||||
}
|
||||
1818
client/web/yarn.lock
Normal file
1818
client/web/yarn.lock
Normal file
File diff suppressed because it is too large
Load Diff
1068
clientupdate/clientupdate.go
Normal file
1068
clientupdate/clientupdate.go
Normal file
File diff suppressed because it is too large
Load Diff
685
clientupdate/clientupdate_test.go
Normal file
685
clientupdate/clientupdate_test.go
Normal file
@@ -0,0 +1,685 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package clientupdate
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"compress/gzip"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"maps"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestUpdateDebianAptSourcesListBytes(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
toTrack string
|
||||
in string
|
||||
want string // empty means want no change
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "stable-to-unstable",
|
||||
toTrack: UnstableTrack,
|
||||
in: "# Tailscale packages for debian buster\ndeb https://pkgs.tailscale.com/stable/debian bullseye main\n",
|
||||
want: "# Tailscale packages for debian buster\ndeb https://pkgs.tailscale.com/unstable/debian bullseye main\n",
|
||||
},
|
||||
{
|
||||
name: "stable-unchanged",
|
||||
toTrack: StableTrack,
|
||||
in: "# Tailscale packages for debian buster\ndeb https://pkgs.tailscale.com/stable/debian bullseye main\n",
|
||||
},
|
||||
{
|
||||
name: "if-both-stable-and-unstable-dont-change",
|
||||
toTrack: StableTrack,
|
||||
in: "# Tailscale packages for debian buster\n" +
|
||||
"deb https://pkgs.tailscale.com/stable/debian bullseye main\n" +
|
||||
"deb https://pkgs.tailscale.com/unstable/debian bullseye main\n",
|
||||
},
|
||||
{
|
||||
name: "if-both-stable-and-unstable-dont-change-unstable",
|
||||
toTrack: UnstableTrack,
|
||||
in: "# Tailscale packages for debian buster\n" +
|
||||
"deb https://pkgs.tailscale.com/stable/debian bullseye main\n" +
|
||||
"deb https://pkgs.tailscale.com/unstable/debian bullseye main\n",
|
||||
},
|
||||
{
|
||||
name: "signed-by-form",
|
||||
toTrack: UnstableTrack,
|
||||
in: "# Tailscale packages for ubuntu jammy\ndeb [signed-by=/usr/share/keyrings/tailscale-archive-keyring.gpg] https://pkgs.tailscale.com/stable/ubuntu jammy main\n",
|
||||
want: "# Tailscale packages for ubuntu jammy\ndeb [signed-by=/usr/share/keyrings/tailscale-archive-keyring.gpg] https://pkgs.tailscale.com/unstable/ubuntu jammy main\n",
|
||||
},
|
||||
{
|
||||
name: "unsupported-lines",
|
||||
toTrack: UnstableTrack,
|
||||
in: "# Tailscale packages for ubuntu jammy\ndeb [signed-by=/usr/share/keyrings/tailscale-archive-keyring.gpg] https://pkgs.tailscale.com/foobar/ubuntu jammy main\n",
|
||||
wantErr: "unexpected/unsupported /etc/apt/sources.list.d/tailscale.list contents",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
newContent, err := updateDebianAptSourcesListBytes([]byte(tt.in), tt.toTrack)
|
||||
if err != nil {
|
||||
if err.Error() != tt.wantErr {
|
||||
t.Fatalf("error = %v; want %q", err, tt.wantErr)
|
||||
}
|
||||
return
|
||||
}
|
||||
if tt.wantErr != "" {
|
||||
t.Fatalf("got no error; want %q", tt.wantErr)
|
||||
}
|
||||
var gotChange string
|
||||
if string(newContent) != tt.in {
|
||||
gotChange = string(newContent)
|
||||
}
|
||||
if gotChange != tt.want {
|
||||
t.Errorf("wrong result\n got: %q\nwant: %q", gotChange, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateYUMRepoTrack(t *testing.T) {
|
||||
tests := []struct {
|
||||
desc string
|
||||
before string
|
||||
track string
|
||||
after string
|
||||
rewrote bool
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
desc: "same track",
|
||||
before: `
|
||||
[tailscale-stable]
|
||||
name=Tailscale stable
|
||||
baseurl=https://pkgs.tailscale.com/stable/fedora/$basearch
|
||||
enabled=1
|
||||
type=rpm
|
||||
repo_gpgcheck=1
|
||||
gpgcheck=0
|
||||
gpgkey=https://pkgs.tailscale.com/stable/fedora/repo.gpg
|
||||
`,
|
||||
track: StableTrack,
|
||||
after: `
|
||||
[tailscale-stable]
|
||||
name=Tailscale stable
|
||||
baseurl=https://pkgs.tailscale.com/stable/fedora/$basearch
|
||||
enabled=1
|
||||
type=rpm
|
||||
repo_gpgcheck=1
|
||||
gpgcheck=0
|
||||
gpgkey=https://pkgs.tailscale.com/stable/fedora/repo.gpg
|
||||
`,
|
||||
},
|
||||
{
|
||||
desc: "change track",
|
||||
before: `
|
||||
[tailscale-stable]
|
||||
name=Tailscale stable
|
||||
baseurl=https://pkgs.tailscale.com/stable/fedora/$basearch
|
||||
enabled=1
|
||||
type=rpm
|
||||
repo_gpgcheck=1
|
||||
gpgcheck=0
|
||||
gpgkey=https://pkgs.tailscale.com/stable/fedora/repo.gpg
|
||||
`,
|
||||
track: UnstableTrack,
|
||||
after: `
|
||||
[tailscale-unstable]
|
||||
name=Tailscale unstable
|
||||
baseurl=https://pkgs.tailscale.com/unstable/fedora/$basearch
|
||||
enabled=1
|
||||
type=rpm
|
||||
repo_gpgcheck=1
|
||||
gpgcheck=0
|
||||
gpgkey=https://pkgs.tailscale.com/unstable/fedora/repo.gpg
|
||||
`,
|
||||
rewrote: true,
|
||||
},
|
||||
{
|
||||
desc: "non-tailscale repo file",
|
||||
before: `
|
||||
[fedora]
|
||||
name=Fedora $releasever - $basearch
|
||||
#baseurl=http://download.example/pub/fedora/linux/releases/$releasever/Everything/$basearch/os/
|
||||
metalink=https://mirrors.fedoraproject.org/metalink?repo=fedora-$releasever&arch=$basearch
|
||||
enabled=1
|
||||
countme=1
|
||||
metadata_expire=7d
|
||||
repo_gpgcheck=0
|
||||
type=rpm
|
||||
gpgcheck=1
|
||||
gpgkey=file:///etc/pki/rpm-gpg/RPM-GPG-KEY-fedora-$releasever-$basearch
|
||||
skip_if_unavailable=False
|
||||
`,
|
||||
track: StableTrack,
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.desc, func(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "tailscale.repo")
|
||||
if err := os.WriteFile(path, []byte(tt.before), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
rewrote, err := updateYUMRepoTrack(path, tt.track)
|
||||
if err == nil && tt.wantErr {
|
||||
t.Fatal("got nil error, want non-nil")
|
||||
}
|
||||
if err != nil && !tt.wantErr {
|
||||
t.Fatalf("got error %q, want nil", err)
|
||||
}
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if rewrote != tt.rewrote {
|
||||
t.Errorf("got rewrote flag %v, want %v", rewrote, tt.rewrote)
|
||||
}
|
||||
|
||||
after, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(after) != tt.after {
|
||||
t.Errorf("got repo file after update:\n%swant:\n%s", after, tt.after)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAlpinePackageVersion(t *testing.T) {
|
||||
tests := []struct {
|
||||
desc string
|
||||
out string
|
||||
want string
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
desc: "valid version",
|
||||
out: `
|
||||
tailscale-1.44.2-r0 description:
|
||||
The easiest, most secure way to use WireGuard and 2FA
|
||||
|
||||
tailscale-1.44.2-r0 webpage:
|
||||
https://tailscale.com/
|
||||
|
||||
tailscale-1.44.2-r0 installed size:
|
||||
32 MiB
|
||||
`,
|
||||
want: "1.44.2",
|
||||
},
|
||||
{
|
||||
desc: "wrong package output",
|
||||
out: `
|
||||
busybox-1.36.1-r0 description:
|
||||
Size optimized toolbox of many common UNIX utilities
|
||||
|
||||
busybox-1.36.1-r0 webpage:
|
||||
https://busybox.net/
|
||||
|
||||
busybox-1.36.1-r0 installed size:
|
||||
924 KiB
|
||||
`,
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
desc: "missing version",
|
||||
out: `
|
||||
tailscale description:
|
||||
The easiest, most secure way to use WireGuard and 2FA
|
||||
|
||||
tailscale webpage:
|
||||
https://tailscale.com/
|
||||
|
||||
tailscale installed size:
|
||||
32 MiB
|
||||
`,
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
desc: "empty output",
|
||||
out: "",
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.desc, func(t *testing.T) {
|
||||
got, err := parseAlpinePackageVersion([]byte(tt.out))
|
||||
if err == nil && tt.wantErr {
|
||||
t.Fatalf("got nil error and version %q, want non-nil error", got)
|
||||
}
|
||||
if err != nil && !tt.wantErr {
|
||||
t.Fatalf("got error: %q, want nil", err)
|
||||
}
|
||||
if got != tt.want {
|
||||
t.Fatalf("got version: %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSynoArch(t *testing.T) {
|
||||
tests := []struct {
|
||||
goarch string
|
||||
synoinfoUnique string
|
||||
want string
|
||||
wantErr bool
|
||||
}{
|
||||
{goarch: "amd64", synoinfoUnique: "synology_x86_224", want: "x86_64"},
|
||||
{goarch: "arm64", synoinfoUnique: "synology_armv8_124", want: "armv8"},
|
||||
{goarch: "386", synoinfoUnique: "synology_i686_415play", want: "i686"},
|
||||
{goarch: "arm", synoinfoUnique: "synology_88f6281_213air", want: "88f6281"},
|
||||
{goarch: "arm", synoinfoUnique: "synology_88f6282_413j", want: "88f6282"},
|
||||
{goarch: "arm", synoinfoUnique: "synology_hi3535_NVR1218", want: "hi3535"},
|
||||
{goarch: "arm", synoinfoUnique: "synology_alpine_1517", want: "alpine"},
|
||||
{goarch: "arm", synoinfoUnique: "synology_armada370_216se", want: "armada370"},
|
||||
{goarch: "arm", synoinfoUnique: "synology_armada375_115", want: "armada375"},
|
||||
{goarch: "arm", synoinfoUnique: "synology_armada38x_419slim", want: "armada38x"},
|
||||
{goarch: "arm", synoinfoUnique: "synology_armadaxp_RS815", want: "armadaxp"},
|
||||
{goarch: "arm", synoinfoUnique: "synology_comcerto2k_414j", want: "comcerto2k"},
|
||||
{goarch: "arm", synoinfoUnique: "synology_monaco_216play", want: "monaco"},
|
||||
{goarch: "ppc64", synoinfoUnique: "synology_qoriq_413", wantErr: true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(fmt.Sprintf("%s-%s", tt.goarch, tt.synoinfoUnique), func(t *testing.T) {
|
||||
synoinfoConfPath := filepath.Join(t.TempDir(), "synoinfo.conf")
|
||||
if err := os.WriteFile(
|
||||
synoinfoConfPath,
|
||||
[]byte(fmt.Sprintf("unique=%q\n", tt.synoinfoUnique)),
|
||||
0600,
|
||||
); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := synoArch(tt.goarch, synoinfoConfPath)
|
||||
if err != nil {
|
||||
if !tt.wantErr {
|
||||
t.Fatalf("got unexpected error %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if tt.wantErr {
|
||||
t.Fatalf("got %q, expected an error", got)
|
||||
}
|
||||
if got != tt.want {
|
||||
t.Errorf("got %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSynoinfo(t *testing.T) {
|
||||
tests := []struct {
|
||||
desc string
|
||||
content string
|
||||
want string
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
desc: "double-quoted",
|
||||
content: `
|
||||
company_title="Synology"
|
||||
unique="synology_88f6281_213air"
|
||||
`,
|
||||
want: "88f6281",
|
||||
},
|
||||
{
|
||||
desc: "single-quoted",
|
||||
content: `
|
||||
company_title="Synology"
|
||||
unique='synology_88f6281_213air'
|
||||
`,
|
||||
want: "88f6281",
|
||||
},
|
||||
{
|
||||
desc: "unquoted",
|
||||
content: `
|
||||
company_title="Synology"
|
||||
unique=synology_88f6281_213air
|
||||
`,
|
||||
want: "88f6281",
|
||||
},
|
||||
{
|
||||
desc: "missing unique",
|
||||
content: `
|
||||
company_title="Synology"
|
||||
`,
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
desc: "empty unique",
|
||||
content: `
|
||||
company_title="Synology"
|
||||
unique=
|
||||
`,
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
desc: "empty unique double-quoted",
|
||||
content: `
|
||||
company_title="Synology"
|
||||
unique=""
|
||||
`,
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
desc: "empty unique single-quoted",
|
||||
content: `
|
||||
company_title="Synology"
|
||||
unique=''
|
||||
`,
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
desc: "malformed unique",
|
||||
content: `
|
||||
company_title="Synology"
|
||||
unique="synology_88f6281"
|
||||
`,
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
desc: "empty file",
|
||||
content: ``,
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
desc: "empty lines and comments",
|
||||
content: `
|
||||
|
||||
# In a file named synoinfo? Shocking!
|
||||
company_title="Synology"
|
||||
|
||||
|
||||
# unique= is_a_field_that_follows
|
||||
unique="synology_88f6281_213air"
|
||||
|
||||
`,
|
||||
want: "88f6281",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.desc, func(t *testing.T) {
|
||||
synoinfoConfPath := filepath.Join(t.TempDir(), "synoinfo.conf")
|
||||
if err := os.WriteFile(synoinfoConfPath, []byte(tt.content), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := parseSynoinfo(synoinfoConfPath)
|
||||
if err != nil {
|
||||
if !tt.wantErr {
|
||||
t.Fatalf("got unexpected error %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if tt.wantErr {
|
||||
t.Fatalf("got %q, expected an error", got)
|
||||
}
|
||||
if got != tt.want {
|
||||
t.Errorf("got %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnpackLinuxTarball(t *testing.T) {
|
||||
oldBinaryPaths := binaryPaths
|
||||
t.Cleanup(func() { binaryPaths = oldBinaryPaths })
|
||||
|
||||
tests := []struct {
|
||||
desc string
|
||||
tarball map[string]string
|
||||
before map[string]string
|
||||
after map[string]string
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
desc: "success",
|
||||
before: map[string]string{
|
||||
"tailscale": "v1",
|
||||
"tailscaled": "v1",
|
||||
},
|
||||
tarball: map[string]string{
|
||||
"/usr/bin/tailscale": "v2",
|
||||
"/usr/bin/tailscaled": "v2",
|
||||
},
|
||||
after: map[string]string{
|
||||
"tailscale": "v2",
|
||||
"tailscaled": "v2",
|
||||
},
|
||||
},
|
||||
{
|
||||
desc: "don't touch unrelated files",
|
||||
before: map[string]string{
|
||||
"tailscale": "v1",
|
||||
"tailscaled": "v1",
|
||||
"foo": "bar",
|
||||
},
|
||||
tarball: map[string]string{
|
||||
"/usr/bin/tailscale": "v2",
|
||||
"/usr/bin/tailscaled": "v2",
|
||||
},
|
||||
after: map[string]string{
|
||||
"tailscale": "v2",
|
||||
"tailscaled": "v2",
|
||||
"foo": "bar",
|
||||
},
|
||||
},
|
||||
{
|
||||
desc: "unmodified",
|
||||
before: map[string]string{
|
||||
"tailscale": "v1",
|
||||
"tailscaled": "v1",
|
||||
},
|
||||
tarball: map[string]string{
|
||||
"/usr/bin/tailscale": "v1",
|
||||
"/usr/bin/tailscaled": "v1",
|
||||
},
|
||||
after: map[string]string{
|
||||
"tailscale": "v1",
|
||||
"tailscaled": "v1",
|
||||
},
|
||||
},
|
||||
{
|
||||
desc: "ignore extra tarball files",
|
||||
before: map[string]string{
|
||||
"tailscale": "v1",
|
||||
"tailscaled": "v1",
|
||||
},
|
||||
tarball: map[string]string{
|
||||
"/usr/bin/tailscale": "v2",
|
||||
"/usr/bin/tailscaled": "v2",
|
||||
"/systemd/tailscaled.service": "v2",
|
||||
},
|
||||
after: map[string]string{
|
||||
"tailscale": "v2",
|
||||
"tailscaled": "v2",
|
||||
},
|
||||
},
|
||||
{
|
||||
desc: "tarball missing tailscaled",
|
||||
before: map[string]string{
|
||||
"tailscale": "v1",
|
||||
"tailscaled": "v1",
|
||||
},
|
||||
tarball: map[string]string{
|
||||
"/usr/bin/tailscale": "v2",
|
||||
},
|
||||
after: map[string]string{
|
||||
"tailscale": "v1",
|
||||
"tailscale.new": "v2",
|
||||
"tailscaled": "v1",
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
desc: "duplicate tailscale binary",
|
||||
before: map[string]string{
|
||||
"tailscale": "v1",
|
||||
"tailscaled": "v1",
|
||||
},
|
||||
tarball: map[string]string{
|
||||
"/usr/bin/tailscale": "v2",
|
||||
"/usr/sbin/tailscale": "v2",
|
||||
"/usr/bin/tailscaled": "v2",
|
||||
},
|
||||
after: map[string]string{
|
||||
"tailscale": "v1",
|
||||
"tailscale.new": "v2",
|
||||
"tailscaled": "v1",
|
||||
"tailscaled.new": "v2",
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
desc: "empty archive",
|
||||
before: map[string]string{
|
||||
"tailscale": "v1",
|
||||
"tailscaled": "v1",
|
||||
},
|
||||
tarball: map[string]string{},
|
||||
after: map[string]string{
|
||||
"tailscale": "v1",
|
||||
"tailscaled": "v1",
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.desc, func(t *testing.T) {
|
||||
// Swap out binaryPaths function to point at dummy file paths.
|
||||
tmp := t.TempDir()
|
||||
tailscalePath := filepath.Join(tmp, "tailscale")
|
||||
tailscaledPath := filepath.Join(tmp, "tailscaled")
|
||||
binaryPaths = func() (string, string, error) {
|
||||
return tailscalePath, tailscaledPath, nil
|
||||
}
|
||||
for name, content := range tt.before {
|
||||
if err := os.WriteFile(filepath.Join(tmp, name), []byte(content), 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
tarPath := filepath.Join(tmp, "tailscale.tgz")
|
||||
genTarball(t, tarPath, tt.tarball)
|
||||
|
||||
up := &Updater{Arguments: Arguments{Logf: t.Logf}}
|
||||
err := up.unpackLinuxTarball(tarPath)
|
||||
if err != nil {
|
||||
if !tt.wantErr {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
} else if tt.wantErr {
|
||||
t.Fatalf("unpack succeeded, expected an error")
|
||||
}
|
||||
|
||||
gotAfter := make(map[string]string)
|
||||
err = filepath.WalkDir(tmp, func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if d.Type().IsDir() {
|
||||
return nil
|
||||
}
|
||||
if path == tarPath {
|
||||
return nil
|
||||
}
|
||||
content, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
path = filepath.ToSlash(path)
|
||||
base := filepath.ToSlash(tmp)
|
||||
gotAfter[strings.TrimPrefix(path, base+"/")] = string(content)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if !maps.Equal(gotAfter, tt.after) {
|
||||
t.Errorf("files after unpack: %+v, want %+v", gotAfter, tt.after)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func genTarball(t *testing.T, path string, files map[string]string) {
|
||||
f, err := os.Create(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer f.Close()
|
||||
gw := gzip.NewWriter(f)
|
||||
defer gw.Close()
|
||||
tw := tar.NewWriter(gw)
|
||||
defer tw.Close()
|
||||
for file, content := range files {
|
||||
if err := tw.WriteHeader(&tar.Header{
|
||||
Name: file,
|
||||
Size: int64(len(content)),
|
||||
Mode: 0755,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := tw.Write([]byte(content)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteFileOverwrite(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "test")
|
||||
for i := 0; i < 2; i++ {
|
||||
content := fmt.Sprintf("content %d", i)
|
||||
if err := writeFile(strings.NewReader(content), path, 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(got) != content {
|
||||
t.Errorf("got content: %q, want: %q", got, content)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteFileSymlink(t *testing.T) {
|
||||
// Test for a malicious symlink at the destination path.
|
||||
// f2 points to f1 and writeFile(f2) should not end up overwriting f1.
|
||||
tmp := t.TempDir()
|
||||
f1 := filepath.Join(tmp, "f1")
|
||||
if err := os.WriteFile(f1, []byte("old"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f2 := filepath.Join(tmp, "f2")
|
||||
if err := os.Symlink(f1, f2); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := writeFile(strings.NewReader("new"), f2, 0600); err != nil {
|
||||
t.Errorf("writeFile(%q) failed: %v", f2, err)
|
||||
}
|
||||
want := map[string]string{
|
||||
f1: "old",
|
||||
f2: "new",
|
||||
}
|
||||
for f, content := range want {
|
||||
got, err := os.ReadFile(f)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(got) != content {
|
||||
t.Errorf("%q: got content %q, want %q", f, got, content)
|
||||
}
|
||||
}
|
||||
}
|
||||
28
clientupdate/clientupdate_windows.go
Normal file
28
clientupdate/clientupdate_windows.go
Normal file
@@ -0,0 +1,28 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
// Windows-specific stuff that can't go in clientupdate.go because it needs
|
||||
// x/sys/windows.
|
||||
|
||||
package clientupdate
|
||||
|
||||
import (
|
||||
"golang.org/x/sys/windows"
|
||||
"tailscale.com/util/winutil/authenticode"
|
||||
)
|
||||
|
||||
func init() {
|
||||
markTempFileFunc = markTempFileWindows
|
||||
verifyAuthenticode = verifyTailscale
|
||||
}
|
||||
|
||||
func markTempFileWindows(name string) error {
|
||||
name16 := windows.StringToUTF16Ptr(name)
|
||||
return windows.MoveFileEx(name16, nil, windows.MOVEFILE_DELAY_UNTIL_REBOOT)
|
||||
}
|
||||
|
||||
const certSubjectTailscale = "Tailscale Inc."
|
||||
|
||||
func verifyTailscale(path string) error {
|
||||
return authenticode.Verify(path, certSubjectTailscale)
|
||||
}
|
||||
486
clientupdate/distsign/distsign.go
Normal file
486
clientupdate/distsign/distsign.go
Normal file
@@ -0,0 +1,486 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
// Package distsign implements signature and validation of arbitrary
|
||||
// distributable files.
|
||||
//
|
||||
// There are 3 parties in this exchange:
|
||||
// - builder, which creates files, signs them with signing keys and publishes
|
||||
// to server
|
||||
// - server, which distributes public signing keys, files and signatures
|
||||
// - client, which downloads files and signatures from server, and validates
|
||||
// the signatures
|
||||
//
|
||||
// There are 2 types of keys:
|
||||
// - signing keys, that sign individual distributable files on the builder
|
||||
// - root keys, that sign signing keys and are kept offline
|
||||
//
|
||||
// root keys -(sign)-> signing keys -(sign)-> files
|
||||
//
|
||||
// All keys are asymmetric Ed25519 key pairs.
|
||||
//
|
||||
// The server serves static files under some known prefix. The kinds of files are:
|
||||
// - distsign.pub - bundle of PEM-encoded public signing keys
|
||||
// - distsign.pub.sig - signature of distsign.pub using one of the root keys
|
||||
// - $file - any distributable file
|
||||
// - $file.sig - signature of $file using any of the signing keys
|
||||
//
|
||||
// The root public keys are baked into the client software at compile time.
|
||||
// These keys are long-lived and prove the validity of current signing keys
|
||||
// from distsign.pub. To rotate root keys, a new client release must be
|
||||
// published, they are not rotated dynamically. There are multiple root keys in
|
||||
// different locations specifically to allow this rotation without using the
|
||||
// discarded root key for any new signatures.
|
||||
//
|
||||
// The signing public keys are fetched by the client dynamically before every
|
||||
// download and can be rotated more readily, assuming that most deployed
|
||||
// clients trust the root keys used to issue fresh signing keys.
|
||||
package distsign
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"encoding/binary"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
"hash"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/hdevalence/ed25519consensus"
|
||||
"golang.org/x/crypto/blake2s"
|
||||
"tailscale.com/net/tshttpproxy"
|
||||
"tailscale.com/types/logger"
|
||||
"tailscale.com/util/httpm"
|
||||
"tailscale.com/util/must"
|
||||
)
|
||||
|
||||
const (
|
||||
pemTypeRootPrivate = "ROOT PRIVATE KEY"
|
||||
pemTypeRootPublic = "ROOT PUBLIC KEY"
|
||||
pemTypeSigningPrivate = "SIGNING PRIVATE KEY"
|
||||
pemTypeSigningPublic = "SIGNING PUBLIC KEY"
|
||||
|
||||
downloadSizeLimit = 1 << 29 // 512MB
|
||||
signingKeysSizeLimit = 1 << 20 // 1MB
|
||||
signatureSizeLimit = ed25519.SignatureSize
|
||||
)
|
||||
|
||||
// RootKey is a root key used to sign signing keys.
|
||||
type RootKey struct {
|
||||
k ed25519.PrivateKey
|
||||
}
|
||||
|
||||
// GenerateRootKey generates a new root key pair and encodes it as PEM.
|
||||
func GenerateRootKey() (priv, pub []byte, err error) {
|
||||
pub, priv, err = ed25519.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return pem.EncodeToMemory(&pem.Block{
|
||||
Type: pemTypeRootPrivate,
|
||||
Bytes: []byte(priv),
|
||||
}), pem.EncodeToMemory(&pem.Block{
|
||||
Type: pemTypeRootPublic,
|
||||
Bytes: []byte(pub),
|
||||
}), nil
|
||||
}
|
||||
|
||||
// ParseRootKey parses the PEM-encoded private root key. The key must be in the
|
||||
// same format as returned by GenerateRootKey.
|
||||
func ParseRootKey(privKey []byte) (*RootKey, error) {
|
||||
k, err := parsePrivateKey(privKey, pemTypeRootPrivate)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse root key: %w", err)
|
||||
}
|
||||
return &RootKey{k: k}, nil
|
||||
}
|
||||
|
||||
// SignSigningKeys signs the bundle of public signing keys. The bundle must be
|
||||
// a sequence of PEM blocks joined with newlines.
|
||||
func (r *RootKey) SignSigningKeys(pubBundle []byte) ([]byte, error) {
|
||||
if _, err := ParseSigningKeyBundle(pubBundle); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ed25519.Sign(r.k, pubBundle), nil
|
||||
}
|
||||
|
||||
// SigningKey is a signing key used to sign packages.
|
||||
type SigningKey struct {
|
||||
k ed25519.PrivateKey
|
||||
}
|
||||
|
||||
// GenerateSigningKey generates a new signing key pair and encodes it as PEM.
|
||||
func GenerateSigningKey() (priv, pub []byte, err error) {
|
||||
pub, priv, err = ed25519.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return pem.EncodeToMemory(&pem.Block{
|
||||
Type: pemTypeSigningPrivate,
|
||||
Bytes: []byte(priv),
|
||||
}), pem.EncodeToMemory(&pem.Block{
|
||||
Type: pemTypeSigningPublic,
|
||||
Bytes: []byte(pub),
|
||||
}), nil
|
||||
}
|
||||
|
||||
// ParseSigningKey parses the PEM-encoded private signing key. The key must be
|
||||
// in the same format as returned by GenerateSigningKey.
|
||||
func ParseSigningKey(privKey []byte) (*SigningKey, error) {
|
||||
k, err := parsePrivateKey(privKey, pemTypeSigningPrivate)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse root key: %w", err)
|
||||
}
|
||||
return &SigningKey{k: k}, nil
|
||||
}
|
||||
|
||||
// SignPackageHash signs the hash and the length of a package. Use PackageHash
|
||||
// to compute the inputs.
|
||||
func (s *SigningKey) SignPackageHash(hash []byte, len int64) ([]byte, error) {
|
||||
if len <= 0 {
|
||||
return nil, fmt.Errorf("package length must be positive, got %d", len)
|
||||
}
|
||||
msg := binary.LittleEndian.AppendUint64(hash, uint64(len))
|
||||
return ed25519.Sign(s.k, msg), nil
|
||||
}
|
||||
|
||||
// PackageHash is a hash.Hash that counts the number of bytes written. Use it
|
||||
// to get the hash and length inputs to SigningKey.SignPackageHash.
|
||||
type PackageHash struct {
|
||||
hash.Hash
|
||||
len int64
|
||||
}
|
||||
|
||||
// NewPackageHash returns an initialized PackageHash using BLAKE2s.
|
||||
func NewPackageHash() *PackageHash {
|
||||
h, err := blake2s.New256(nil)
|
||||
if err != nil {
|
||||
// Should never happen with a nil key passed to blake2s.
|
||||
panic(err)
|
||||
}
|
||||
return &PackageHash{Hash: h}
|
||||
}
|
||||
|
||||
func (ph *PackageHash) Write(b []byte) (int, error) {
|
||||
ph.len += int64(len(b))
|
||||
return ph.Hash.Write(b)
|
||||
}
|
||||
|
||||
// Reset the PackageHash to its initial state.
|
||||
func (ph *PackageHash) Reset() {
|
||||
ph.len = 0
|
||||
ph.Hash.Reset()
|
||||
}
|
||||
|
||||
// Len returns the total number of bytes written.
|
||||
func (ph *PackageHash) Len() int64 { return ph.len }
|
||||
|
||||
// Client downloads and validates files from a distribution server.
|
||||
type Client struct {
|
||||
logf logger.Logf
|
||||
roots []ed25519.PublicKey
|
||||
pkgsAddr *url.URL
|
||||
}
|
||||
|
||||
// NewClient returns a new client for distribution server located at pkgsAddr,
|
||||
// and uses embedded root keys from the roots/ subdirectory of this package.
|
||||
func NewClient(logf logger.Logf, pkgsAddr string) (*Client, error) {
|
||||
if logf == nil {
|
||||
logf = log.Printf
|
||||
}
|
||||
u, err := url.Parse(pkgsAddr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid pkgsAddr %q: %w", pkgsAddr, err)
|
||||
}
|
||||
return &Client{logf: logf, roots: roots(), pkgsAddr: u}, nil
|
||||
}
|
||||
|
||||
func (c *Client) url(path string) string {
|
||||
return c.pkgsAddr.JoinPath(path).String()
|
||||
}
|
||||
|
||||
// Download fetches a file at path srcPath from pkgsAddr passed in NewClient.
|
||||
// The file is downloaded to dstPath and its signature is validated using the
|
||||
// embedded root keys. Download returns an error if anything goes wrong with
|
||||
// the actual file download or with signature validation.
|
||||
func (c *Client) Download(ctx context.Context, srcPath, dstPath string) error {
|
||||
// Always fetch a fresh signing key.
|
||||
sigPub, err := c.signingKeys()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
srcURL := c.url(srcPath)
|
||||
sigURL := srcURL + ".sig"
|
||||
|
||||
c.logf("Downloading %q", srcURL)
|
||||
dstPathUnverified := dstPath + ".unverified"
|
||||
hash, len, err := c.download(ctx, srcURL, dstPathUnverified, downloadSizeLimit)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
c.logf("Downloading %q", sigURL)
|
||||
sig, err := fetch(sigURL, signatureSizeLimit)
|
||||
if err != nil {
|
||||
// Best-effort clean up of downloaded package.
|
||||
os.Remove(dstPathUnverified)
|
||||
return err
|
||||
}
|
||||
msg := binary.LittleEndian.AppendUint64(hash, uint64(len))
|
||||
if !VerifyAny(sigPub, msg, sig) {
|
||||
// Best-effort clean up of downloaded package.
|
||||
os.Remove(dstPathUnverified)
|
||||
return fmt.Errorf("signature %q for file %q does not validate with the current release signing key; either you are under attack, or attempting to download an old version of Tailscale which was signed with an older signing key", sigURL, srcURL)
|
||||
}
|
||||
c.logf("Signature OK")
|
||||
|
||||
if err := os.Rename(dstPathUnverified, dstPath); err != nil {
|
||||
return fmt.Errorf("failed to move %q to %q after signature validation", dstPathUnverified, dstPath)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateLocalBinary fetches the latest signature associated with the binary
|
||||
// at srcURLPath and uses it to validate the file located on disk via
|
||||
// localFilePath. ValidateLocalBinary returns an error if anything goes wrong
|
||||
// with the signature download or with signature validation.
|
||||
func (c *Client) ValidateLocalBinary(srcURLPath, localFilePath string) error {
|
||||
// Always fetch a fresh signing key.
|
||||
sigPub, err := c.signingKeys()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
srcURL := c.url(srcURLPath)
|
||||
sigURL := srcURL + ".sig"
|
||||
|
||||
localFile, err := os.Open(localFilePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer localFile.Close()
|
||||
|
||||
h := NewPackageHash()
|
||||
_, err = io.Copy(h, localFile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
hash, hashLen := h.Sum(nil), h.Len()
|
||||
|
||||
c.logf("Downloading %q", sigURL)
|
||||
sig, err := fetch(sigURL, signatureSizeLimit)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
msg := binary.LittleEndian.AppendUint64(hash, uint64(hashLen))
|
||||
if !VerifyAny(sigPub, msg, sig) {
|
||||
return fmt.Errorf("signature %q for file %q does not validate with the current release signing key; either you are under attack, or attempting to download an old version of Tailscale which was signed with an older signing key", sigURL, localFilePath)
|
||||
}
|
||||
c.logf("Signature OK")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// signingKeys fetches current signing keys from the server and validates them
|
||||
// against the roots. Should be called before validation of any downloaded file
|
||||
// to get the fresh keys.
|
||||
func (c *Client) signingKeys() ([]ed25519.PublicKey, error) {
|
||||
keyURL := c.url("distsign.pub")
|
||||
sigURL := keyURL + ".sig"
|
||||
raw, err := fetch(keyURL, signingKeysSizeLimit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sig, err := fetch(sigURL, signatureSizeLimit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !VerifyAny(c.roots, raw, sig) {
|
||||
return nil, fmt.Errorf("signature %q for key %q does not validate with any known root key; either you are under attack, or running a very old version of Tailscale with outdated root keys", sigURL, keyURL)
|
||||
}
|
||||
|
||||
keys, err := ParseSigningKeyBundle(raw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot parse signing key bundle from %q: %w", keyURL, err)
|
||||
}
|
||||
return keys, nil
|
||||
}
|
||||
|
||||
// fetch reads the response body from url into memory, up to limit bytes.
|
||||
func fetch(url string, limit int64) ([]byte, error) {
|
||||
resp, err := http.Get(url)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
return io.ReadAll(io.LimitReader(resp.Body, limit))
|
||||
}
|
||||
|
||||
// download writes the response body of url into a local file at dst, up to
|
||||
// limit bytes. On success, the returned value is a BLAKE2s hash of the file.
|
||||
func (c *Client) download(ctx context.Context, url, dst string, limit int64) ([]byte, int64, error) {
|
||||
tr := http.DefaultTransport.(*http.Transport).Clone()
|
||||
tr.Proxy = tshttpproxy.ProxyFromEnvironment
|
||||
defer tr.CloseIdleConnections()
|
||||
hc := &http.Client{Transport: tr}
|
||||
|
||||
quickCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||
defer cancel()
|
||||
headReq := must.Get(http.NewRequestWithContext(quickCtx, httpm.HEAD, url, nil))
|
||||
|
||||
res, err := hc.Do(headReq)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if res.StatusCode != http.StatusOK {
|
||||
return nil, 0, fmt.Errorf("HEAD %q: %v", url, res.Status)
|
||||
}
|
||||
if res.ContentLength <= 0 {
|
||||
return nil, 0, fmt.Errorf("HEAD %q: unexpected Content-Length %v", url, res.ContentLength)
|
||||
}
|
||||
c.logf("Download size: %v", res.ContentLength)
|
||||
|
||||
dlReq := must.Get(http.NewRequestWithContext(ctx, httpm.GET, url, nil))
|
||||
dlRes, err := hc.Do(dlReq)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
defer dlRes.Body.Close()
|
||||
// TODO(bradfitz): resume from existing partial file on disk
|
||||
if dlRes.StatusCode != http.StatusOK {
|
||||
return nil, 0, fmt.Errorf("GET %q: %v", url, dlRes.Status)
|
||||
}
|
||||
|
||||
of, err := os.Create(dst)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
defer of.Close()
|
||||
pw := &progressWriter{total: res.ContentLength, logf: c.logf}
|
||||
h := NewPackageHash()
|
||||
n, err := io.Copy(io.MultiWriter(of, h, pw), io.LimitReader(dlRes.Body, limit))
|
||||
if err != nil {
|
||||
return nil, n, err
|
||||
}
|
||||
if n != res.ContentLength {
|
||||
return nil, n, fmt.Errorf("GET %q: downloaded %v, want %v", url, n, res.ContentLength)
|
||||
}
|
||||
if err := dlRes.Body.Close(); err != nil {
|
||||
return nil, n, err
|
||||
}
|
||||
if err := of.Close(); err != nil {
|
||||
return nil, n, err
|
||||
}
|
||||
pw.print()
|
||||
|
||||
return h.Sum(nil), h.Len(), nil
|
||||
}
|
||||
|
||||
type progressWriter struct {
|
||||
done int64
|
||||
total int64
|
||||
lastPrint time.Time
|
||||
logf logger.Logf
|
||||
}
|
||||
|
||||
func (pw *progressWriter) Write(p []byte) (n int, err error) {
|
||||
pw.done += int64(len(p))
|
||||
if time.Since(pw.lastPrint) > 2*time.Second {
|
||||
pw.print()
|
||||
}
|
||||
return len(p), nil
|
||||
}
|
||||
|
||||
func (pw *progressWriter) print() {
|
||||
pw.lastPrint = time.Now()
|
||||
pw.logf("Downloaded %v/%v (%.1f%%)", pw.done, pw.total, float64(pw.done)/float64(pw.total)*100)
|
||||
}
|
||||
|
||||
func parsePrivateKey(data []byte, typeTag string) (ed25519.PrivateKey, error) {
|
||||
b, rest := pem.Decode(data)
|
||||
if b == nil {
|
||||
return nil, errors.New("failed to decode PEM data")
|
||||
}
|
||||
if len(rest) > 0 {
|
||||
return nil, errors.New("trailing PEM data")
|
||||
}
|
||||
if b.Type != typeTag {
|
||||
return nil, fmt.Errorf("PEM type is %q, want %q", b.Type, typeTag)
|
||||
}
|
||||
if len(b.Bytes) != ed25519.PrivateKeySize {
|
||||
return nil, errors.New("private key has incorrect length for an Ed25519 private key")
|
||||
}
|
||||
return ed25519.PrivateKey(b.Bytes), nil
|
||||
}
|
||||
|
||||
// ParseSigningKeyBundle parses the bundle of PEM-encoded public signing keys.
|
||||
func ParseSigningKeyBundle(bundle []byte) ([]ed25519.PublicKey, error) {
|
||||
return parsePublicKeyBundle(bundle, pemTypeSigningPublic)
|
||||
}
|
||||
|
||||
// ParseRootKeyBundle parses the bundle of PEM-encoded public root keys.
|
||||
func ParseRootKeyBundle(bundle []byte) ([]ed25519.PublicKey, error) {
|
||||
return parsePublicKeyBundle(bundle, pemTypeRootPublic)
|
||||
}
|
||||
|
||||
func parsePublicKeyBundle(bundle []byte, typeTag string) ([]ed25519.PublicKey, error) {
|
||||
var keys []ed25519.PublicKey
|
||||
for len(bundle) > 0 {
|
||||
pub, rest, err := parsePublicKey(bundle, typeTag)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
keys = append(keys, pub)
|
||||
bundle = rest
|
||||
}
|
||||
if len(keys) == 0 {
|
||||
return nil, errors.New("no signing keys found in the bundle")
|
||||
}
|
||||
return keys, nil
|
||||
}
|
||||
|
||||
func parseSinglePublicKey(data []byte, typeTag string) (ed25519.PublicKey, error) {
|
||||
pub, rest, err := parsePublicKey(data, typeTag)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(rest) > 0 {
|
||||
return nil, errors.New("trailing PEM data")
|
||||
}
|
||||
return pub, err
|
||||
}
|
||||
|
||||
func parsePublicKey(data []byte, typeTag string) (pub ed25519.PublicKey, rest []byte, retErr error) {
|
||||
b, rest := pem.Decode(data)
|
||||
if b == nil {
|
||||
return nil, nil, errors.New("failed to decode PEM data")
|
||||
}
|
||||
if b.Type != typeTag {
|
||||
return nil, nil, fmt.Errorf("PEM type is %q, want %q", b.Type, typeTag)
|
||||
}
|
||||
if len(b.Bytes) != ed25519.PublicKeySize {
|
||||
return nil, nil, errors.New("public key has incorrect length for an Ed25519 public key")
|
||||
}
|
||||
return ed25519.PublicKey(b.Bytes), rest, nil
|
||||
}
|
||||
|
||||
// VerifyAny verifies whether sig is valid for msg using any of the keys.
|
||||
// VerifyAny will panic if any of the keys have the wrong size for Ed25519.
|
||||
func VerifyAny(keys []ed25519.PublicKey, msg, sig []byte) bool {
|
||||
for _, k := range keys {
|
||||
if ed25519consensus.Verify(k, msg, sig) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
585
clientupdate/distsign/distsign_test.go
Normal file
585
clientupdate/distsign/distsign_test.go
Normal file
@@ -0,0 +1,585 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package distsign
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/ed25519"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"golang.org/x/crypto/blake2s"
|
||||
)
|
||||
|
||||
func TestDownload(t *testing.T) {
|
||||
srv := newTestServer(t)
|
||||
c := srv.client(t)
|
||||
|
||||
tests := []struct {
|
||||
desc string
|
||||
before func(*testing.T)
|
||||
src string
|
||||
want []byte
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
desc: "missing file",
|
||||
before: func(*testing.T) {},
|
||||
src: "hello",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
desc: "success",
|
||||
before: func(*testing.T) {
|
||||
srv.addSigned("hello", []byte("world"))
|
||||
},
|
||||
src: "hello",
|
||||
want: []byte("world"),
|
||||
},
|
||||
{
|
||||
desc: "no signature",
|
||||
before: func(*testing.T) {
|
||||
srv.add("hello", []byte("world"))
|
||||
},
|
||||
src: "hello",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
desc: "bad signature",
|
||||
before: func(*testing.T) {
|
||||
srv.add("hello", []byte("world"))
|
||||
srv.add("hello.sig", []byte("potato"))
|
||||
},
|
||||
src: "hello",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
desc: "signed with untrusted key",
|
||||
before: func(t *testing.T) {
|
||||
srv.add("hello", []byte("world"))
|
||||
srv.add("hello.sig", newSigningKeyPair(t).sign([]byte("world")))
|
||||
},
|
||||
src: "hello",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
desc: "signed with root key",
|
||||
before: func(t *testing.T) {
|
||||
srv.add("hello", []byte("world"))
|
||||
srv.add("hello.sig", ed25519.Sign(srv.roots[0].k, []byte("world")))
|
||||
},
|
||||
src: "hello",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
desc: "bad signing key signature",
|
||||
before: func(t *testing.T) {
|
||||
srv.add("distsign.pub.sig", []byte("potato"))
|
||||
srv.addSigned("hello", []byte("world"))
|
||||
},
|
||||
src: "hello",
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.desc, func(t *testing.T) {
|
||||
srv.reset()
|
||||
tt.before(t)
|
||||
|
||||
dst := filepath.Join(t.TempDir(), tt.src)
|
||||
t.Cleanup(func() {
|
||||
os.Remove(dst)
|
||||
})
|
||||
err := c.Download(context.Background(), tt.src, dst)
|
||||
if err != nil {
|
||||
if tt.wantErr {
|
||||
return
|
||||
}
|
||||
t.Fatalf("unexpected error from Download(%q): %v", tt.src, err)
|
||||
}
|
||||
if tt.wantErr {
|
||||
t.Fatalf("Download(%q) succeeded, expected an error", tt.src)
|
||||
}
|
||||
got, err := os.ReadFile(dst)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !bytes.Equal(tt.want, got) {
|
||||
t.Errorf("Download(%q): got %q, want %q", tt.src, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateLocalBinary(t *testing.T) {
|
||||
srv := newTestServer(t)
|
||||
c := srv.client(t)
|
||||
|
||||
tests := []struct {
|
||||
desc string
|
||||
before func(*testing.T)
|
||||
src string
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
desc: "missing file",
|
||||
before: func(*testing.T) {},
|
||||
src: "hello",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
desc: "success",
|
||||
before: func(*testing.T) {
|
||||
srv.addSigned("hello", []byte("world"))
|
||||
},
|
||||
src: "hello",
|
||||
},
|
||||
{
|
||||
desc: "contents changed",
|
||||
before: func(*testing.T) {
|
||||
srv.addSigned("hello", []byte("new world"))
|
||||
},
|
||||
src: "hello",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
desc: "no signature",
|
||||
before: func(*testing.T) {
|
||||
srv.add("hello", []byte("world"))
|
||||
},
|
||||
src: "hello",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
desc: "bad signature",
|
||||
before: func(*testing.T) {
|
||||
srv.add("hello", []byte("world"))
|
||||
srv.add("hello.sig", []byte("potato"))
|
||||
},
|
||||
src: "hello",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
desc: "signed with untrusted key",
|
||||
before: func(t *testing.T) {
|
||||
srv.add("hello", []byte("world"))
|
||||
srv.add("hello.sig", newSigningKeyPair(t).sign([]byte("world")))
|
||||
},
|
||||
src: "hello",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
desc: "signed with root key",
|
||||
before: func(t *testing.T) {
|
||||
srv.add("hello", []byte("world"))
|
||||
srv.add("hello.sig", ed25519.Sign(srv.roots[0].k, []byte("world")))
|
||||
},
|
||||
src: "hello",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
desc: "bad signing key signature",
|
||||
before: func(t *testing.T) {
|
||||
srv.add("distsign.pub.sig", []byte("potato"))
|
||||
srv.addSigned("hello", []byte("world"))
|
||||
},
|
||||
src: "hello",
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.desc, func(t *testing.T) {
|
||||
srv.reset()
|
||||
|
||||
// First just do a successful Download.
|
||||
want := []byte("world")
|
||||
srv.addSigned("hello", want)
|
||||
dst := filepath.Join(t.TempDir(), tt.src)
|
||||
err := c.Download(context.Background(), tt.src, dst)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error from Download(%q): %v", tt.src, err)
|
||||
}
|
||||
got, err := os.ReadFile(dst)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !bytes.Equal(want, got) {
|
||||
t.Errorf("Download(%q): got %q, want %q", tt.src, got, want)
|
||||
}
|
||||
|
||||
// Now we reset srv with the test case and validate against the local dst.
|
||||
srv.reset()
|
||||
tt.before(t)
|
||||
|
||||
err = c.ValidateLocalBinary(tt.src, dst)
|
||||
if err != nil {
|
||||
if tt.wantErr {
|
||||
return
|
||||
}
|
||||
t.Fatalf("unexpected error from ValidateLocalBinary(%q): %v", tt.src, err)
|
||||
}
|
||||
if tt.wantErr {
|
||||
t.Fatalf("ValidateLocalBinary(%q) succeeded, expected an error", tt.src)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRotateRoot(t *testing.T) {
|
||||
srv := newTestServer(t)
|
||||
c1 := srv.client(t)
|
||||
ctx := context.Background()
|
||||
|
||||
srv.addSigned("hello", []byte("world"))
|
||||
if err := c1.Download(ctx, "hello", filepath.Join(t.TempDir(), "hello")); err != nil {
|
||||
t.Fatalf("Download failed on a fresh server: %v", err)
|
||||
}
|
||||
|
||||
// Remove first root and replace it with a new key.
|
||||
srv.roots = append(srv.roots[1:], newRootKeyPair(t))
|
||||
|
||||
// Old client can still download files because it still trusts the old
|
||||
// root key.
|
||||
if err := c1.Download(ctx, "hello", filepath.Join(t.TempDir(), "hello")); err != nil {
|
||||
t.Fatalf("Download failed after root rotation on old client: %v", err)
|
||||
}
|
||||
// New client should fail download because current signing key is signed by
|
||||
// the revoked root that new client doesn't trust.
|
||||
c2 := srv.client(t)
|
||||
if err := c2.Download(ctx, "hello", filepath.Join(t.TempDir(), "hello")); err == nil {
|
||||
t.Fatalf("Download succeeded on new client, but signing key is signed with revoked root key")
|
||||
}
|
||||
// Re-sign signing key with another valid root that client still trusts.
|
||||
srv.resignSigningKeys()
|
||||
// Both old and new clients should now be able to download.
|
||||
//
|
||||
// Note: we don't need to re-sign the "hello" file because signing key
|
||||
// didn't change (only signing key's signature).
|
||||
if err := c1.Download(ctx, "hello", filepath.Join(t.TempDir(), "hello")); err != nil {
|
||||
t.Fatalf("Download failed after root rotation on old client with re-signed signing key: %v", err)
|
||||
}
|
||||
if err := c2.Download(ctx, "hello", filepath.Join(t.TempDir(), "hello")); err != nil {
|
||||
t.Fatalf("Download failed after root rotation on new client with re-signed signing key: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRotateSigning(t *testing.T) {
|
||||
srv := newTestServer(t)
|
||||
c := srv.client(t)
|
||||
ctx := context.Background()
|
||||
|
||||
srv.addSigned("hello", []byte("world"))
|
||||
if err := c.Download(ctx, "hello", filepath.Join(t.TempDir(), "hello")); err != nil {
|
||||
t.Fatalf("Download failed on a fresh server: %v", err)
|
||||
}
|
||||
|
||||
// Replace signing key but don't publish it yet.
|
||||
srv.sign = append(srv.sign, newSigningKeyPair(t))
|
||||
if err := c.Download(ctx, "hello", filepath.Join(t.TempDir(), "hello")); err != nil {
|
||||
t.Fatalf("Download failed after new signing key added but before publishing it: %v", err)
|
||||
}
|
||||
|
||||
// Publish new signing key bundle with both keys.
|
||||
srv.resignSigningKeys()
|
||||
if err := c.Download(ctx, "hello", filepath.Join(t.TempDir(), "hello")); err != nil {
|
||||
t.Fatalf("Download failed after new signing key was published: %v", err)
|
||||
}
|
||||
|
||||
// Re-sign the "hello" file with new signing key.
|
||||
srv.add("hello.sig", srv.sign[1].sign([]byte("world")))
|
||||
if err := c.Download(ctx, "hello", filepath.Join(t.TempDir(), "hello")); err != nil {
|
||||
t.Fatalf("Download failed after re-signing with new signing key: %v", err)
|
||||
}
|
||||
|
||||
// Drop the old signing key.
|
||||
srv.sign = srv.sign[1:]
|
||||
srv.resignSigningKeys()
|
||||
if err := c.Download(ctx, "hello", filepath.Join(t.TempDir(), "hello")); err != nil {
|
||||
t.Fatalf("Download failed after removing old signing key: %v", err)
|
||||
}
|
||||
|
||||
// Add another key and re-sign the file with it *before* publishing.
|
||||
srv.sign = append(srv.sign, newSigningKeyPair(t))
|
||||
srv.add("hello.sig", srv.sign[1].sign([]byte("world")))
|
||||
if err := c.Download(ctx, "hello", filepath.Join(t.TempDir(), "hello")); err == nil {
|
||||
t.Fatalf("Download succeeded when signed with a not-yet-published signing key")
|
||||
}
|
||||
// Fix this by publishing the new key.
|
||||
srv.resignSigningKeys()
|
||||
if err := c.Download(ctx, "hello", filepath.Join(t.TempDir(), "hello")); err != nil {
|
||||
t.Fatalf("Download failed after publishing new signing key: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseRootKey(t *testing.T) {
|
||||
tests := []struct {
|
||||
desc string
|
||||
generate func() ([]byte, []byte, error)
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
desc: "valid",
|
||||
generate: GenerateRootKey,
|
||||
},
|
||||
{
|
||||
desc: "signing",
|
||||
generate: GenerateSigningKey,
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
desc: "nil",
|
||||
generate: func() ([]byte, []byte, error) { return nil, nil, nil },
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
desc: "invalid PEM tag",
|
||||
generate: func() ([]byte, []byte, error) {
|
||||
priv, pub, err := GenerateRootKey()
|
||||
priv = bytes.Replace(priv, []byte("ROOT "), nil, -1)
|
||||
return priv, pub, err
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
desc: "not PEM",
|
||||
generate: func() ([]byte, []byte, error) { return []byte("s3cr3t"), nil, nil },
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.desc, func(t *testing.T) {
|
||||
priv, _, err := tt.generate()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r, err := ParseRootKey(priv)
|
||||
if err != nil {
|
||||
if tt.wantErr {
|
||||
return
|
||||
}
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if tt.wantErr {
|
||||
t.Fatal("expected non-nil error")
|
||||
}
|
||||
if r == nil {
|
||||
t.Errorf("got nil error and nil RootKey")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSigningKey(t *testing.T) {
|
||||
tests := []struct {
|
||||
desc string
|
||||
generate func() ([]byte, []byte, error)
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
desc: "valid",
|
||||
generate: GenerateSigningKey,
|
||||
},
|
||||
{
|
||||
desc: "root",
|
||||
generate: GenerateRootKey,
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
desc: "nil",
|
||||
generate: func() ([]byte, []byte, error) { return nil, nil, nil },
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
desc: "invalid PEM tag",
|
||||
generate: func() ([]byte, []byte, error) {
|
||||
priv, pub, err := GenerateSigningKey()
|
||||
priv = bytes.Replace(priv, []byte("SIGNING "), nil, -1)
|
||||
return priv, pub, err
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
desc: "not PEM",
|
||||
generate: func() ([]byte, []byte, error) { return []byte("s3cr3t"), nil, nil },
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.desc, func(t *testing.T) {
|
||||
priv, _, err := tt.generate()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r, err := ParseSigningKey(priv)
|
||||
if err != nil {
|
||||
if tt.wantErr {
|
||||
return
|
||||
}
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if tt.wantErr {
|
||||
t.Fatal("expected non-nil error")
|
||||
}
|
||||
if r == nil {
|
||||
t.Errorf("got nil error and nil SigningKey")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type testServer struct {
|
||||
roots []rootKeyPair
|
||||
sign []signingKeyPair
|
||||
files map[string][]byte
|
||||
srv *httptest.Server
|
||||
}
|
||||
|
||||
func newTestServer(t *testing.T) *testServer {
|
||||
var roots []rootKeyPair
|
||||
for i := 0; i < 3; i++ {
|
||||
roots = append(roots, newRootKeyPair(t))
|
||||
}
|
||||
|
||||
ts := &testServer{
|
||||
roots: roots,
|
||||
sign: []signingKeyPair{newSigningKeyPair(t)},
|
||||
}
|
||||
ts.reset()
|
||||
ts.srv = httptest.NewServer(ts)
|
||||
t.Cleanup(ts.srv.Close)
|
||||
return ts
|
||||
}
|
||||
|
||||
func (s *testServer) client(t *testing.T) *Client {
|
||||
roots := make([]ed25519.PublicKey, 0, len(s.roots))
|
||||
for _, r := range s.roots {
|
||||
pub, err := parseSinglePublicKey(r.pubRaw, pemTypeRootPublic)
|
||||
if err != nil {
|
||||
t.Fatalf("parsePublicKey: %v", err)
|
||||
}
|
||||
roots = append(roots, pub)
|
||||
}
|
||||
u, err := url.Parse(s.srv.URL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return &Client{
|
||||
logf: t.Logf,
|
||||
roots: roots,
|
||||
pkgsAddr: u,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *testServer) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
path := strings.TrimPrefix(r.URL.Path, "/")
|
||||
data, ok := s.files[path]
|
||||
if !ok {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
w.Write(data)
|
||||
}
|
||||
|
||||
func (s *testServer) addSigned(name string, data []byte) {
|
||||
s.files[name] = data
|
||||
s.files[name+".sig"] = s.sign[0].sign(data)
|
||||
}
|
||||
|
||||
func (s *testServer) add(name string, data []byte) {
|
||||
s.files[name] = data
|
||||
}
|
||||
|
||||
func (s *testServer) reset() {
|
||||
s.files = make(map[string][]byte)
|
||||
s.resignSigningKeys()
|
||||
}
|
||||
|
||||
func (s *testServer) resignSigningKeys() {
|
||||
var pubs [][]byte
|
||||
for _, k := range s.sign {
|
||||
pubs = append(pubs, k.pubRaw)
|
||||
}
|
||||
bundle := bytes.Join(pubs, []byte("\n"))
|
||||
sig := s.roots[0].sign(bundle)
|
||||
s.files["distsign.pub"] = bundle
|
||||
s.files["distsign.pub.sig"] = sig
|
||||
}
|
||||
|
||||
type rootKeyPair struct {
|
||||
*RootKey
|
||||
keyPair
|
||||
}
|
||||
|
||||
func newRootKeyPair(t *testing.T) rootKeyPair {
|
||||
privRaw, pubRaw, err := GenerateRootKey()
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateRootKey: %v", err)
|
||||
}
|
||||
kp := keyPair{
|
||||
privRaw: privRaw,
|
||||
pubRaw: pubRaw,
|
||||
}
|
||||
priv, err := parsePrivateKey(kp.privRaw, pemTypeRootPrivate)
|
||||
if err != nil {
|
||||
t.Fatalf("parsePrivateKey: %v", err)
|
||||
}
|
||||
return rootKeyPair{
|
||||
RootKey: &RootKey{k: priv},
|
||||
keyPair: kp,
|
||||
}
|
||||
}
|
||||
|
||||
func (s rootKeyPair) sign(bundle []byte) []byte {
|
||||
sig, err := s.SignSigningKeys(bundle)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return sig
|
||||
}
|
||||
|
||||
type signingKeyPair struct {
|
||||
*SigningKey
|
||||
keyPair
|
||||
}
|
||||
|
||||
func newSigningKeyPair(t *testing.T) signingKeyPair {
|
||||
privRaw, pubRaw, err := GenerateSigningKey()
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateSigningKey: %v", err)
|
||||
}
|
||||
kp := keyPair{
|
||||
privRaw: privRaw,
|
||||
pubRaw: pubRaw,
|
||||
}
|
||||
priv, err := parsePrivateKey(kp.privRaw, pemTypeSigningPrivate)
|
||||
if err != nil {
|
||||
t.Fatalf("parsePrivateKey: %v", err)
|
||||
}
|
||||
return signingKeyPair{
|
||||
SigningKey: &SigningKey{k: priv},
|
||||
keyPair: kp,
|
||||
}
|
||||
}
|
||||
|
||||
func (s signingKeyPair) sign(blob []byte) []byte {
|
||||
hash := blake2s.Sum256(blob)
|
||||
sig, err := s.SignPackageHash(hash[:], int64(len(blob)))
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return sig
|
||||
}
|
||||
|
||||
type keyPair struct {
|
||||
privRaw []byte
|
||||
pubRaw []byte
|
||||
}
|
||||
54
clientupdate/distsign/roots.go
Normal file
54
clientupdate/distsign/roots.go
Normal file
@@ -0,0 +1,54 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package distsign
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"embed"
|
||||
"errors"
|
||||
"fmt"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
)
|
||||
|
||||
//go:embed roots
|
||||
var rootsFS embed.FS
|
||||
|
||||
var roots = sync.OnceValue(func() []ed25519.PublicKey {
|
||||
roots, err := parseRoots()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return roots
|
||||
})
|
||||
|
||||
func parseRoots() ([]ed25519.PublicKey, error) {
|
||||
files, err := rootsFS.ReadDir("roots")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var keys []ed25519.PublicKey
|
||||
for _, f := range files {
|
||||
if !f.Type().IsRegular() {
|
||||
continue
|
||||
}
|
||||
if filepath.Ext(f.Name()) != ".pem" {
|
||||
continue
|
||||
}
|
||||
raw, err := rootsFS.ReadFile(path.Join("roots", f.Name()))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
key, err := parseSinglePublicKey(raw, pemTypeRootPublic)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parsing root key %q: %w", f.Name(), err)
|
||||
}
|
||||
keys = append(keys, key)
|
||||
}
|
||||
if len(keys) == 0 {
|
||||
return nil, errors.New("no embedded root keys, please check clientupdate/distsign/roots/")
|
||||
}
|
||||
return keys, nil
|
||||
}
|
||||
3
clientupdate/distsign/roots/crawshaw-root.pem
Executable file
3
clientupdate/distsign/roots/crawshaw-root.pem
Executable file
@@ -0,0 +1,3 @@
|
||||
-----BEGIN ROOT PUBLIC KEY-----
|
||||
Psrabv2YNiEDhPlnLVSMtB5EKACm7zxvKxfvYD4i7X8=
|
||||
-----END ROOT PUBLIC KEY-----
|
||||
3
clientupdate/distsign/roots/distsign-prod-root-1-pub.pem
Normal file
3
clientupdate/distsign/roots/distsign-prod-root-1-pub.pem
Normal file
@@ -0,0 +1,3 @@
|
||||
-----BEGIN ROOT PUBLIC KEY-----
|
||||
ZjjKhUHBtLNRSO1dhOTjrXJGJ8lDe1594WM2XDuheVQ=
|
||||
-----END ROOT PUBLIC KEY-----
|
||||
16
clientupdate/distsign/roots_test.go
Normal file
16
clientupdate/distsign/roots_test.go
Normal file
@@ -0,0 +1,16 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package distsign
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestParseRoots(t *testing.T) {
|
||||
roots, err := parseRoots()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(roots) == 0 {
|
||||
t.Error("parseRoots returned no root keys")
|
||||
}
|
||||
}
|
||||
37
clientupdate/systemd_linux.go
Normal file
37
clientupdate/systemd_linux.go
Normal file
@@ -0,0 +1,37 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package clientupdate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/coreos/go-systemd/v22/dbus"
|
||||
)
|
||||
|
||||
func restartSystemdUnit(ctx context.Context) error {
|
||||
c, err := dbus.NewWithContext(ctx)
|
||||
if err != nil {
|
||||
// Likely not a systemd-managed distro.
|
||||
return errors.ErrUnsupported
|
||||
}
|
||||
defer c.Close()
|
||||
if err := c.ReloadContext(ctx); err != nil {
|
||||
return fmt.Errorf("failed to reload tailsacled.service: %w", err)
|
||||
}
|
||||
ch := make(chan string, 1)
|
||||
if _, err := c.RestartUnitContext(ctx, "tailscaled.service", "replace", ch); err != nil {
|
||||
return fmt.Errorf("failed to restart tailsacled.service: %w", err)
|
||||
}
|
||||
select {
|
||||
case res := <-ch:
|
||||
if res != "done" {
|
||||
return fmt.Errorf("systemd service restart failed with result %q", res)
|
||||
}
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
15
clientupdate/systemd_other.go
Normal file
15
clientupdate/systemd_other.go
Normal file
@@ -0,0 +1,15 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
//go:build !linux
|
||||
|
||||
package clientupdate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
)
|
||||
|
||||
func restartSystemdUnit(ctx context.Context) error {
|
||||
return errors.ErrUnsupported
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
// Copyright (c) 2021 Tailscale Inc & AUTHORS All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style
|
||||
// license that can be found in the LICENSE file.
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
// Program addlicense adds a license header to a file.
|
||||
// It is intended for use with 'go generate',
|
||||
@@ -15,26 +14,24 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
year = flag.Int("year", 0, "copyright year")
|
||||
file = flag.String("file", "", "file to modify")
|
||||
)
|
||||
|
||||
func usage() {
|
||||
fmt.Fprintf(os.Stderr, `
|
||||
usage: addlicense -year YEAR -file FILE <subcommand args...>
|
||||
usage: addlicense -file FILE <subcommand args...>
|
||||
`[1:])
|
||||
|
||||
flag.PrintDefaults()
|
||||
fmt.Fprintf(os.Stderr, `
|
||||
addlicense adds a Tailscale license to the beginning of file,
|
||||
using year as the copyright year.
|
||||
addlicense adds a Tailscale license to the beginning of file.
|
||||
|
||||
It is intended for use with 'go generate', so it also runs a subcommand,
|
||||
which presumably creates the file.
|
||||
|
||||
Sample usage:
|
||||
|
||||
addlicense -year 2021 -file pull_strings.go stringer -type=pull
|
||||
addlicense -file pull_strings.go stringer -type=pull
|
||||
`[1:])
|
||||
os.Exit(2)
|
||||
}
|
||||
@@ -54,7 +51,7 @@ func main() {
|
||||
check(err)
|
||||
f, err := os.OpenFile(*file, os.O_TRUNC|os.O_WRONLY, 0644)
|
||||
check(err)
|
||||
_, err = fmt.Fprintf(f, license, *year)
|
||||
_, err = fmt.Fprint(f, license)
|
||||
check(err)
|
||||
_, err = f.Write(b)
|
||||
check(err)
|
||||
@@ -70,8 +67,7 @@ func check(err error) {
|
||||
}
|
||||
|
||||
var license = `
|
||||
// Copyright (c) %d Tailscale Inc & AUTHORS All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style
|
||||
// license that can be found in the LICENSE file.
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
`[1:]
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
// Copyright (c) 2020 Tailscale Inc & AUTHORS All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style
|
||||
// license that can be found in the LICENSE file.
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
// Cloner is a tool to automate the creation of a Clone method.
|
||||
//
|
||||
@@ -22,13 +21,11 @@ import (
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/tools/go/packages"
|
||||
"tailscale.com/util/codegen"
|
||||
)
|
||||
|
||||
var (
|
||||
flagTypes = flag.String("type", "", "comma-separated list of types; required")
|
||||
flagOutput = flag.String("output", "", "output file; required")
|
||||
flagBuildTags = flag.String("tags", "", "compiler build tags to apply")
|
||||
flagCloneFunc = flag.Bool("clonefunc", false, "add a top-level Clone func")
|
||||
)
|
||||
@@ -43,40 +40,28 @@ func main() {
|
||||
}
|
||||
typeNames := strings.Split(*flagTypes, ",")
|
||||
|
||||
cfg := &packages.Config{
|
||||
Mode: packages.NeedTypes | packages.NeedTypesInfo | packages.NeedSyntax | packages.NeedName,
|
||||
Tests: false,
|
||||
}
|
||||
if *flagBuildTags != "" {
|
||||
cfg.BuildFlags = []string{"-tags=" + *flagBuildTags}
|
||||
}
|
||||
pkgs, err := packages.Load(cfg, ".")
|
||||
pkg, namedTypes, err := codegen.LoadTypes(*flagBuildTags, ".")
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
if len(pkgs) != 1 {
|
||||
log.Fatalf("wrong number of packages: %d", len(pkgs))
|
||||
}
|
||||
pkg := pkgs[0]
|
||||
it := codegen.NewImportTracker(pkg.Types)
|
||||
buf := new(bytes.Buffer)
|
||||
imports := make(map[string]struct{})
|
||||
namedTypes := codegen.NamedTypes(pkg)
|
||||
for _, typeName := range typeNames {
|
||||
typ, ok := namedTypes[typeName]
|
||||
if !ok {
|
||||
log.Fatalf("could not find type %s", typeName)
|
||||
}
|
||||
gen(buf, imports, typ, pkg.Types)
|
||||
gen(buf, it, typ)
|
||||
}
|
||||
|
||||
w := func(format string, args ...interface{}) {
|
||||
w := func(format string, args ...any) {
|
||||
fmt.Fprintf(buf, format+"\n", args...)
|
||||
}
|
||||
if *flagCloneFunc {
|
||||
w("// Clone duplicates src into dst and reports whether it succeeded.")
|
||||
w("// To succeed, <src, dst> must be of types <*T, *T> or <*T, **T>,")
|
||||
w("// where T is one of %s.", *flagTypes)
|
||||
w("func Clone(dst, src interface{}) bool {")
|
||||
w("func Clone(dst, src any) bool {")
|
||||
w(" switch src := src.(type) {")
|
||||
for _, typeName := range typeNames {
|
||||
w(" case *%s:", typeName)
|
||||
@@ -93,62 +78,13 @@ func main() {
|
||||
w(" return false")
|
||||
w("}")
|
||||
}
|
||||
|
||||
contents := new(bytes.Buffer)
|
||||
var flagArgs []string
|
||||
if *flagTypes != "" {
|
||||
flagArgs = append(flagArgs, "-type="+*flagTypes)
|
||||
}
|
||||
if *flagOutput != "" {
|
||||
flagArgs = append(flagArgs, "-output="+*flagOutput)
|
||||
}
|
||||
if *flagBuildTags != "" {
|
||||
flagArgs = append(flagArgs, "-tags="+*flagBuildTags)
|
||||
}
|
||||
if *flagCloneFunc {
|
||||
flagArgs = append(flagArgs, "-clonefunc")
|
||||
}
|
||||
fmt.Fprintf(contents, header, strings.Join(flagArgs, " "), pkg.Name)
|
||||
fmt.Fprintf(contents, "import (\n")
|
||||
for s := range imports {
|
||||
fmt.Fprintf(contents, "\t%q\n", s)
|
||||
}
|
||||
fmt.Fprintf(contents, ")\n\n")
|
||||
contents.Write(buf.Bytes())
|
||||
|
||||
output := *flagOutput
|
||||
if output == "" {
|
||||
flag.Usage()
|
||||
os.Exit(2)
|
||||
}
|
||||
if err := codegen.WriteFormatted(contents.Bytes(), output); err != nil {
|
||||
cloneOutput := pkg.Name + "_clone.go"
|
||||
if err := codegen.WritePackageFile("tailscale.com/cmd/cloner", pkg, cloneOutput, it, buf); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
const header = `// Copyright (c) 2020 Tailscale Inc & AUTHORS All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
// Code generated by tailscale.com/cmd/cloner; DO NOT EDIT.
|
||||
//` + `go:generate` + ` go run tailscale.com/cmd/cloner %s
|
||||
|
||||
package %s
|
||||
|
||||
`
|
||||
|
||||
func gen(buf *bytes.Buffer, imports map[string]struct{}, typ *types.Named, thisPkg *types.Package) {
|
||||
pkgQual := func(pkg *types.Package) string {
|
||||
if thisPkg == pkg {
|
||||
return ""
|
||||
}
|
||||
imports[pkg.Path()] = struct{}{}
|
||||
return pkg.Name()
|
||||
}
|
||||
importedName := func(t types.Type) string {
|
||||
return types.TypeString(t, pkgQual)
|
||||
}
|
||||
|
||||
func gen(buf *bytes.Buffer, it *codegen.ImportTracker, typ *types.Named) {
|
||||
t, ok := typ.Underlying().(*types.Struct)
|
||||
if !ok {
|
||||
return
|
||||
@@ -158,7 +94,7 @@ func gen(buf *bytes.Buffer, imports map[string]struct{}, typ *types.Named, thisP
|
||||
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)
|
||||
writef := func(format string, args ...interface{}) {
|
||||
writef := func(format string, args ...any) {
|
||||
fmt.Fprintf(buf, "\t"+format+"\n", args...)
|
||||
}
|
||||
writef("if src == nil {")
|
||||
@@ -169,25 +105,42 @@ func gen(buf *bytes.Buffer, imports map[string]struct{}, typ *types.Named, thisP
|
||||
for i := 0; i < t.NumFields(); i++ {
|
||||
fname := t.Field(i).Name()
|
||||
ft := t.Field(i).Type()
|
||||
if !codegen.ContainsPointers(ft) {
|
||||
if !codegen.ContainsPointers(ft) || codegen.HasNoClone(t.Tag(i)) {
|
||||
continue
|
||||
}
|
||||
if named, _ := ft.(*types.Named); named != nil && !hasBasicUnderlying(ft) {
|
||||
writef("dst.%s = *src.%s.Clone()", fname, fname)
|
||||
continue
|
||||
if named, _ := ft.(*types.Named); named != nil {
|
||||
if codegen.IsViewType(ft) {
|
||||
writef("dst.%s = src.%s", fname, fname)
|
||||
continue
|
||||
}
|
||||
if !hasBasicUnderlying(ft) {
|
||||
writef("dst.%s = *src.%s.Clone()", fname, fname)
|
||||
continue
|
||||
}
|
||||
}
|
||||
switch ft := ft.Underlying().(type) {
|
||||
case *types.Slice:
|
||||
if codegen.ContainsPointers(ft.Elem()) {
|
||||
n := importedName(ft.Elem())
|
||||
n := it.QualifiedName(ft.Elem())
|
||||
writef("if src.%s != nil {", fname)
|
||||
writef("dst.%s = make([]%s, len(src.%s))", fname, n, fname)
|
||||
writef("for i := range dst.%s {", fname)
|
||||
if _, isPtr := ft.Elem().(*types.Pointer); isPtr {
|
||||
writef("\tdst.%s[i] = src.%s[i].Clone()", fname, 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("}")
|
||||
} else {
|
||||
writef("\tdst.%s[i] = src.%s[i].Clone()", fname, fname)
|
||||
}
|
||||
} 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 {
|
||||
writef("\tdst.%s[i] = *src.%s[i].Clone()", fname, fname)
|
||||
}
|
||||
writef("}")
|
||||
writef("}")
|
||||
} else {
|
||||
writef("dst.%s = append(src.%s[:0:0], src.%s...)", fname, fname, fname)
|
||||
}
|
||||
@@ -196,34 +149,41 @@ func gen(buf *bytes.Buffer, imports map[string]struct{}, typ *types.Named, thisP
|
||||
writef("dst.%s = src.%s.Clone()", fname, fname)
|
||||
continue
|
||||
}
|
||||
n := importedName(ft.Elem())
|
||||
it.Import("tailscale.com/types/ptr")
|
||||
writef("if dst.%s != nil {", fname)
|
||||
writef("\tdst.%s = new(%s)", fname, n)
|
||||
writef("\t*dst.%s = *src.%s", fname, fname)
|
||||
writef("\tdst.%s = ptr.To(*src.%s)", fname, fname)
|
||||
if codegen.ContainsPointers(ft.Elem()) {
|
||||
writef("\t" + `panic("TODO pointers in pointers")`)
|
||||
}
|
||||
writef("}")
|
||||
case *types.Map:
|
||||
writef("if dst.%s != nil {", fname)
|
||||
writef("\tdst.%s = map[%s]%s{}", fname, importedName(ft.Key()), importedName(ft.Elem()))
|
||||
if sliceType, isSlice := ft.Elem().(*types.Slice); isSlice {
|
||||
n := importedName(sliceType.Elem())
|
||||
elem := ft.Elem()
|
||||
if sliceType, isSlice := elem.(*types.Slice); isSlice {
|
||||
n := it.QualifiedName(sliceType.Elem())
|
||||
writef("if dst.%s != nil {", fname)
|
||||
writef("\tdst.%s = map[%s]%s{}", fname, it.QualifiedName(ft.Key()), it.QualifiedName(elem))
|
||||
writef("\tfor k := range src.%s {", fname)
|
||||
// use zero-length slice instead of nil to ensure
|
||||
// the key is always copied.
|
||||
writef("\t\tdst.%s[k] = append([]%s{}, src.%s[k]...)", fname, n, fname)
|
||||
writef("\t}")
|
||||
} else if codegen.ContainsPointers(ft.Elem()) {
|
||||
writef("}")
|
||||
} else if codegen.ContainsPointers(elem) {
|
||||
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)
|
||||
writef("\t\tdst.%s[k] = v.Clone()", fname)
|
||||
switch elem.(type) {
|
||||
case *types.Pointer:
|
||||
writef("\t\tdst.%s[k] = v.Clone()", fname)
|
||||
default:
|
||||
writef("\t\tdst.%s[k] = *(v.Clone())", fname)
|
||||
}
|
||||
writef("\t}")
|
||||
writef("}")
|
||||
} else {
|
||||
writef("\tfor k, v := range src.%s {", fname)
|
||||
writef("\t\tdst.%s[k] = v", fname)
|
||||
writef("\t}")
|
||||
it.Import("maps")
|
||||
writef("\tdst.%s = maps.Clone(src.%s)", fname, fname)
|
||||
}
|
||||
writef("}")
|
||||
default:
|
||||
writef(`panic("TODO: %s (%T)")`, fname, ft)
|
||||
}
|
||||
@@ -231,9 +191,10 @@ func gen(buf *bytes.Buffer, imports map[string]struct{}, typ *types.Named, thisP
|
||||
writef("return dst")
|
||||
fmt.Fprintf(buf, "}\n\n")
|
||||
|
||||
buf.Write(codegen.AssertStructUnchanged(t, thisPkg, name, "Clone", imports))
|
||||
buf.Write(codegen.AssertStructUnchanged(t, name, "Clone", it))
|
||||
}
|
||||
|
||||
// hasBasicUnderlying reports true when typ.Underlying() is a slice or a map.
|
||||
func hasBasicUnderlying(typ types.Type) bool {
|
||||
switch typ.Underlying().(type) {
|
||||
case *types.Slice, *types.Map:
|
||||
|
||||
60
cmd/cloner/cloner_test.go
Normal file
60
cmd/cloner/cloner_test.go
Normal file
@@ -0,0 +1,60 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
package main
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"tailscale.com/cmd/cloner/clonerex"
|
||||
)
|
||||
|
||||
func TestSliceContainer(t *testing.T) {
|
||||
num := 5
|
||||
examples := []struct {
|
||||
name string
|
||||
in *clonerex.SliceContainer
|
||||
}{
|
||||
{
|
||||
name: "nil",
|
||||
in: nil,
|
||||
},
|
||||
{
|
||||
name: "zero",
|
||||
in: &clonerex.SliceContainer{},
|
||||
},
|
||||
{
|
||||
name: "empty",
|
||||
in: &clonerex.SliceContainer{
|
||||
Slice: []*int{},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "nils",
|
||||
in: &clonerex.SliceContainer{
|
||||
Slice: []*int{nil, nil, nil, nil, nil},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "one",
|
||||
in: &clonerex.SliceContainer{
|
||||
Slice: []*int{&num},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "several",
|
||||
in: &clonerex.SliceContainer{
|
||||
Slice: []*int{&num, &num, &num, &num, &num},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, ex := range examples {
|
||||
t.Run(ex.name, func(t *testing.T) {
|
||||
out := ex.in.Clone()
|
||||
if !reflect.DeepEqual(ex.in, out) {
|
||||
t.Errorf("Clone() = %v, want %v", out, ex.in)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
10
cmd/cloner/clonerex/clonerex.go
Normal file
10
cmd/cloner/clonerex/clonerex.go
Normal file
@@ -0,0 +1,10 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
//go:generate go run tailscale.com/cmd/cloner -clonefunc=true -type SliceContainer
|
||||
|
||||
package clonerex
|
||||
|
||||
type SliceContainer struct {
|
||||
Slice []*int
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user