mirror of
https://github.com/juanfont/headscale.git
synced 2025-08-20 00:27:31 +00:00
Compare commits
1320 Commits
v0.3.0
...
mandatory-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cd2914ab3b | ||
|
|
e85b97143c | ||
|
|
db9ba17920 | ||
|
|
d5ce7d7523 | ||
|
|
2e6687209b | ||
|
|
b781446e86 | ||
|
|
1c9b1c0579 | ||
|
|
ade9552736 | ||
|
|
68403cb76e | ||
|
|
537ecb8db0 | ||
|
|
8f5875efe4 | ||
|
|
98ac88d5ef | ||
|
|
d13338a9fb | ||
|
|
1579ffb66a | ||
|
|
0bfa5302a7 | ||
|
|
b8aad5451d | ||
|
|
61440c42d3 | ||
|
|
18ee6274e1 | ||
|
|
0abfbdc18a | ||
|
|
082a852c5e | ||
|
|
af081e9fd3 | ||
|
|
8b5e8b7dfc | ||
|
|
62d7fae056 | ||
|
|
dd219d0ff6 | ||
|
|
6087e1cf6f | ||
|
|
c47fb1ae54 | ||
|
|
48cec3cd90 | ||
|
|
e54c508c10 | ||
|
|
941e9d9b0f | ||
|
|
b803240dc1 | ||
|
|
bdbf620ece | ||
|
|
e5d22b8a70 | ||
|
|
05c5e2280b | ||
|
|
b41d89946a | ||
|
|
cc0c88a63a | ||
|
|
c06689dec1 | ||
|
|
b85dd7abbd | ||
|
|
6aeaff43aa | ||
|
|
dd26cbd193 | ||
|
|
b0ae3240fd | ||
|
|
41efe98953 | ||
|
|
2b68c90778 | ||
|
|
f19c048569 | ||
|
|
6cc8bbc24f | ||
|
|
03452a8dca | ||
|
|
15ed71315c | ||
|
|
05df8e947a | ||
|
|
b3fa66dbd2 | ||
|
|
a27b386123 | ||
|
|
580db9b58f | ||
|
|
1114449601 | ||
|
|
b47de07eea | ||
|
|
e1fcf0da26 | ||
|
|
dcf3ea567c | ||
|
|
de2ea83b3b | ||
|
|
eb06054a7b | ||
|
|
eb500155e8 | ||
|
|
dc909ba6d7 | ||
|
|
70910c4595 | ||
|
|
54c3e00a1f | ||
|
|
e78c002f5a | ||
|
|
237f7f1027 | ||
|
|
992efbd84a | ||
|
|
e9eb90fa76 | ||
|
|
88378c22fb | ||
|
|
b742379627 | ||
|
|
df37d1a639 | ||
|
|
758b1ba1cb | ||
|
|
435ee36d78 | ||
|
|
35efd8f95a | ||
|
|
09d78c7a05 | ||
|
|
60655c5242 | ||
|
|
22d2443281 | ||
|
|
a70669fca7 | ||
|
|
0720473033 | ||
|
|
e799307e74 | ||
|
|
575f33d183 | ||
|
|
607c1eb316 | ||
|
|
d69dada8ff | ||
|
|
f9e0c13890 | ||
|
|
12a50ac8ac | ||
|
|
b342cf0240 | ||
|
|
e3ff87b7ef | ||
|
|
745696b310 | ||
|
|
23cde8445f | ||
|
|
9d43f589ae | ||
|
|
897d480f4d | ||
|
|
6f172a6e4c | ||
|
|
44a5372c53 | ||
|
|
f2ea6fb30f | ||
|
|
4a4952899b | ||
|
|
b72a8aa7d1 | ||
|
|
e301d0d1df | ||
|
|
75ca91b0f7 | ||
|
|
e208ccc982 | ||
|
|
71a62697aa | ||
|
|
f9c0597875 | ||
|
|
aa3eb5171a | ||
|
|
dcc46af8de | ||
|
|
b61500670c | ||
|
|
ccec534e19 | ||
|
|
9b10457209 | ||
|
|
9a8f605cba | ||
|
|
1246267ead | ||
|
|
a0a56d43f8 | ||
|
|
63d87110f6 | ||
|
|
7c99d963e2 | ||
|
|
a614f158be | ||
|
|
2b6a5173da | ||
|
|
32ac690494 | ||
|
|
0835bffc3c | ||
|
|
c80e364f02 | ||
|
|
5b169010be | ||
|
|
eeded85d9c | ||
|
|
e4d81bbb16 | ||
|
|
1f8c7f427b | ||
|
|
ef422e6988 | ||
|
|
ec4dc68524 | ||
|
|
86ade72c19 | ||
|
|
0c0653df8b | ||
|
|
12b3b5f8f1 | ||
|
|
052dbfe440 | ||
|
|
5310f8692b | ||
|
|
aff6b84250 | ||
|
|
21eee912a3 | ||
|
|
dbb2af0238 | ||
|
|
77fe0b01f7 | ||
|
|
361b4f7f4f | ||
|
|
dec4ee5f73 | ||
|
|
b2dca80e7a | ||
|
|
a455a874ad | ||
|
|
49cd761bf6 | ||
|
|
6477e6a583 | ||
|
|
8a95fe517a | ||
|
|
a9d4fa89dc | ||
|
|
94c5474212 | ||
|
|
d34d617935 | ||
|
|
573008757d | ||
|
|
4c74043f72 | ||
|
|
0551b34de5 | ||
|
|
105812421e | ||
|
|
4a9fd3a680 | ||
|
|
1cb39d914c | ||
|
|
5157f356cb | ||
|
|
7c63412df5 | ||
|
|
82cb6b9ddc | ||
|
|
379017602c | ||
|
|
8bef04d8df | ||
|
|
5e92ddad43 | ||
|
|
e64bee778f | ||
|
|
5e1b12948e | ||
|
|
eea8e7ba6f | ||
|
|
78251ce8ec | ||
|
|
a8649d83c4 | ||
|
|
16b21e8158 | ||
|
|
35616eb861 | ||
|
|
e7bef56718 | ||
|
|
c6b87de959 | ||
|
|
50053e616a | ||
|
|
54cc3c067f | ||
|
|
402a76070f | ||
|
|
9a61725e9f | ||
|
|
6126d6d9b5 | ||
|
|
469551bc5d | ||
|
|
1caa6f5d69 | ||
|
|
ecc26432fd | ||
|
|
caffbd8956 | ||
|
|
fd1e4a1dcd | ||
|
|
acb945841c | ||
|
|
c58ce6f60c | ||
|
|
d6f6939c54 | ||
|
|
e0b9a317f4 | ||
|
|
c159eb7541 | ||
|
|
8a3a0b6403 | ||
|
|
67d6c8f946 | ||
|
|
06e6c29a5b | ||
|
|
a9122c3de3 | ||
|
|
b1bd17f316 | ||
|
|
b39faa124a | ||
|
|
8689a39c96 | ||
|
|
bae8ed3e70 | ||
|
|
08c7076667 | ||
|
|
91b50550ee | ||
|
|
2c7064462a | ||
|
|
d9e7f37280 | ||
|
|
e03b3d558f | ||
|
|
2fd36dd254 | ||
|
|
381598663d | ||
|
|
6d699d3c29 | ||
|
|
ebe59a5a27 | ||
|
|
d55c79e75b | ||
|
|
eda0a9f88a | ||
|
|
47e8442d91 | ||
|
|
f9ce32fe1a | ||
|
|
189e883f91 | ||
|
|
aa506503e2 | ||
|
|
9c2c09fce7 | ||
|
|
5596a0acef | ||
|
|
9687e6768d | ||
|
|
fb85c78e8a | ||
|
|
d27f2bc538 | ||
|
|
8c33907655 | ||
|
|
afb67b6e75 | ||
|
|
69f220fe5c | ||
|
|
c46dfd761c | ||
|
|
95453cba75 | ||
|
|
ed2175706c | ||
|
|
686e45cf27 | ||
|
|
ae6a20e4d9 | ||
|
|
046116656b | ||
|
|
972bef1194 | ||
|
|
4f1f235a2e | ||
|
|
7e4709c13f | ||
|
|
cef0a2b0b3 | ||
|
|
fcdbe7c510 | ||
|
|
995731a29c | ||
|
|
45727dbb21 | ||
|
|
f0a73632e0 | ||
|
|
823cc493f0 | ||
|
|
a86b33f1ff | ||
|
|
28c2bbeb27 | ||
|
|
d4761da27c | ||
|
|
b0c7ebeb7d | ||
|
|
5f375d69b5 | ||
|
|
9eb705a4fb | ||
|
|
1b87396a8c | ||
|
|
bb14bcd4d2 | ||
|
|
48c866b058 | ||
|
|
fe0b43eaaf | ||
|
|
afd4a3706e | ||
|
|
717250adb3 | ||
|
|
67f5c32b49 | ||
|
|
0191ea93ff | ||
|
|
92ffac625e | ||
|
|
bfbcea35a0 | ||
|
|
638a84adb9 | ||
|
|
ec58979ce0 | ||
|
|
7e6e093f17 | ||
|
|
4962335860 | ||
|
|
a37339fa54 | ||
|
|
f7eeb979fb | ||
|
|
f2f8d834e8 | ||
|
|
fe2f75d13d | ||
|
|
52db6188df | ||
|
|
8dca40535f | ||
|
|
f4c302f1fb | ||
|
|
4ca8181dcb | ||
|
|
24a8e198a1 | ||
|
|
9411ec47c3 | ||
|
|
1e8f4dbdff | ||
|
|
9399754489 | ||
|
|
9d1752acbc | ||
|
|
6da2a19d10 | ||
|
|
9ceac5c0fc | ||
|
|
f562ad579a | ||
|
|
bbadeb567a | ||
|
|
69cdfbb56f | ||
|
|
d971f0f0e6 | ||
|
|
650108c7c7 | ||
|
|
baae266db0 | ||
|
|
50af44bc2f | ||
|
|
e3bcc88880 | ||
|
|
14e49885fb | ||
|
|
fbc1843889 | ||
|
|
45d5ab30ff | ||
|
|
d5fd7a5c00 | ||
|
|
b5a59d4e7a | ||
|
|
211fe4034a | ||
|
|
daa75da277 | ||
|
|
25550f8866 | ||
|
|
4bbe0051f6 | ||
|
|
5ab62378ae | ||
|
|
f006860136 | ||
|
|
9c6ce02554 | ||
|
|
960412a335 | ||
|
|
ecb3ee6bfa | ||
|
|
5242025ab3 | ||
|
|
b3d0fb7a93 | ||
|
|
5e167cc00a | ||
|
|
d00251c63e | ||
|
|
4f9ece14c5 | ||
|
|
7bf2a91dd0 | ||
|
|
385dd9cc34 | ||
|
|
602291df61 | ||
|
|
5245f1accc | ||
|
|
91babb5130 | ||
|
|
8798efd353 | ||
|
|
66a12004e7 | ||
|
|
74621e2750 | ||
|
|
74c3c6bb60 | ||
|
|
84b98e716a | ||
|
|
e9f13b6031 | ||
|
|
fe6d47030f | ||
|
|
a19550adbf | ||
|
|
3db88d27de | ||
|
|
a6b7bc5939 | ||
|
|
397b6fc4bf | ||
|
|
7d5e6d3f0f | ||
|
|
7a90c2fba1 | ||
|
|
5cf215a44b | ||
|
|
7916fa8b45 | ||
|
|
5fbef07627 | ||
|
|
21df798f07 | ||
|
|
67bb1fc9dd | ||
|
|
61bfa79be2 | ||
|
|
f073d8f43c | ||
|
|
5f642eef76 | ||
|
|
d8c4c3163b | ||
|
|
9cedbbafd4 | ||
|
|
aceaba60f1 | ||
|
|
7b5ba9f781 | ||
|
|
de59946447 | ||
|
|
97eac3b938 | ||
|
|
fb45138fc1 | ||
|
|
e9949b4c70 | ||
|
|
e482dfeed4 | ||
|
|
9b7d657cbe | ||
|
|
55d746d3f5 | ||
|
|
73497382b7 | ||
|
|
c364c2a382 | ||
|
|
e540679dbd | ||
|
|
86b329d8bf | ||
|
|
b2b2954545 | ||
|
|
a3360b082f | ||
|
|
b721502147 | ||
|
|
1869bff4ba | ||
|
|
0b9dd19ec7 | ||
|
|
b2889bc355 | ||
|
|
28c824acaf | ||
|
|
57f1da6dca | ||
|
|
c9640b2f3e | ||
|
|
546b1e8a05 | ||
|
|
3b54a68f5c | ||
|
|
1b1aac18d2 | ||
|
|
f30ee3d2df | ||
|
|
9f80349471 | ||
|
|
14b23544e4 | ||
|
|
4e54796384 | ||
|
|
c3b68adfed | ||
|
|
0018a78d5a | ||
|
|
50f0270543 | ||
|
|
bb80b679bc | ||
|
|
6fa0903a8e | ||
|
|
2bc8051ae5 | ||
|
|
4841e16386 | ||
|
|
d79ccfc05a | ||
|
|
ead8b68a03 | ||
|
|
3bb4c28c9a | ||
|
|
2fbcc38f8f | ||
|
|
315ff9daf0 | ||
|
|
4078e75b50 | ||
|
|
58bfea4e64 | ||
|
|
e18078d7f8 | ||
|
|
c73b57e7dc | ||
|
|
531298fa59 | ||
|
|
30a2ccd975 | ||
|
|
59e48993f2 | ||
|
|
bfc6f6e0eb | ||
|
|
811d3d510c | ||
|
|
2aba37d2ef | ||
|
|
8853ccd5b4 | ||
|
|
c794f32f58 | ||
|
|
dd8bae8c61 | ||
|
|
1b47ddd583 | ||
|
|
20991d6883 | ||
|
|
96f09e3f30 | ||
|
|
8f40696f35 | ||
|
|
c1845477ef | ||
|
|
1d40de3095 | ||
|
|
2357fb6f80 | ||
|
|
ba8afdb7be | ||
|
|
d9aaa0bdfc | ||
|
|
66ff34c2dd | ||
|
|
150652e939 | ||
|
|
7bdd7748e4 | ||
|
|
0426212348 | ||
|
|
85cf443ac6 | ||
|
|
1b2fff4337 | ||
|
|
8c79165b0d | ||
|
|
7b607b3fe8 | ||
|
|
41fbe47cdf | ||
|
|
af25aa75d9 | ||
|
|
da5250ea32 | ||
|
|
168b1bd579 | ||
|
|
9de5c7f8b8 | ||
|
|
52db80ab0d | ||
|
|
0c3fd16113 | ||
|
|
e05c5e0b93 | ||
|
|
310e7b15c7 | ||
|
|
9e3318ca27 | ||
|
|
e9adfcd678 | ||
|
|
d44b2a7c01 | ||
|
|
5b5ecd52e1 | ||
|
|
eddd62eee0 | ||
|
|
38c27f6bf8 | ||
|
|
90fb9aa4ed | ||
|
|
3af1253a65 | ||
|
|
eb1ce64b7c | ||
|
|
2c9ed63021 | ||
|
|
4c779d306b | ||
|
|
0862f60ff0 | ||
|
|
991175f2aa | ||
|
|
1815040d98 | ||
|
|
71ab4c9b2c | ||
|
|
e0c22a414b | ||
|
|
4e63bba4fe | ||
|
|
445c04baf7 | ||
|
|
ad4e3a89e0 | ||
|
|
6f6018bad5 | ||
|
|
ccd41b9a13 | ||
|
|
d8ce440309 | ||
|
|
0609c97459 | ||
|
|
2f576b2fb1 | ||
|
|
853a5288f1 | ||
|
|
cd0df1e46f | ||
|
|
b195c87418 | ||
|
|
c98a559b4d | ||
|
|
5935b13b67 | ||
|
|
9e619fc020 | ||
|
|
45bcf39894 | ||
|
|
0a1db89d33 | ||
|
|
dbfb9e16e0 | ||
|
|
8aa2606853 | ||
|
|
a238a8b33a | ||
|
|
74f26d3685 | ||
|
|
e66f8b0eeb | ||
|
|
e7b69dbf91 | ||
|
|
13f23d2e7e | ||
|
|
7a86321252 | ||
|
|
7aace7eb6b | ||
|
|
7a6be36f46 | ||
|
|
bb27c80bad | ||
|
|
c0c3b7d511 | ||
|
|
6220836050 | ||
|
|
b122d06f12 | ||
|
|
6f9ed958ca | ||
|
|
39ce59fcb1 | ||
|
|
052fccdc98 | ||
|
|
17411b65f3 | ||
|
|
bf7ee78324 | ||
|
|
fbe5054a67 | ||
|
|
761147ea3b | ||
|
|
25ccf5ef18 | ||
|
|
b4f8961e44 | ||
|
|
726ccc8c1f | ||
|
|
126e694f26 | ||
|
|
ab45cd37f8 | ||
|
|
f59071ff1c | ||
|
|
537cd35cb2 | ||
|
|
56b6528e3b | ||
|
|
bae7ba46de | ||
|
|
fa197cc183 | ||
|
|
00c69ce50c | ||
|
|
a6e22387fd | ||
|
|
a730f007d8 | ||
|
|
3393363a67 | ||
|
|
8218ef96ef | ||
|
|
e8e573de62 | ||
|
|
05db1b7109 | ||
|
|
6e14fdf0d3 | ||
|
|
1fd57a3375 | ||
|
|
b4259fcd79 | ||
|
|
f9137f3bb0 | ||
|
|
b1a9b1ada1 | ||
|
|
b8e9024845 | ||
|
|
70d82ea184 | ||
|
|
9dc20580c7 | ||
|
|
4d60aeae18 | ||
|
|
67d1dd984f | ||
|
|
b02f8dd45d | ||
|
|
3837f1714a | ||
|
|
ed5498ef86 | ||
|
|
e2f8c69e2e | ||
|
|
beb3e9abc2 | ||
|
|
78039f4cea | ||
|
|
ed39b91f71 | ||
|
|
8f632e9062 | ||
|
|
a32175f791 | ||
|
|
d35fb8bba0 | ||
|
|
115d0cbe85 | ||
|
|
1a6e5d8770 | ||
|
|
3a3aecb774 | ||
|
|
8b40343277 | ||
|
|
7ec8346179 | ||
|
|
46cdce00af | ||
|
|
86f3f26a18 | ||
|
|
febbb6006f | ||
|
|
1d68509463 | ||
|
|
b6d0c4f2aa | ||
|
|
2c057c2d89 | ||
|
|
cf7effda1b | ||
|
|
19effe7034 | ||
|
|
41053482b3 | ||
|
|
e463283a58 | ||
|
|
99814b468b | ||
|
|
163ecb2a6b | ||
|
|
4660b265d9 | ||
|
|
1b6bad0b63 | ||
|
|
26623d794b | ||
|
|
e9cc60e49c | ||
|
|
be2a28dd61 | ||
|
|
cec236ce24 | ||
|
|
45d331da99 | ||
|
|
897fa558b0 | ||
|
|
d971cf1295 | ||
|
|
42bed58329 | ||
|
|
d9f52efe70 | ||
|
|
25b5eb8d7f | ||
|
|
81c60939c9 | ||
|
|
4edc96d14d | ||
|
|
6b7c74133d | ||
|
|
8da029bd14 | ||
|
|
1d01103b67 | ||
|
|
5df100539c | ||
|
|
11c86acbe3 | ||
|
|
86f36f9a43 | ||
|
|
271cb71754 | ||
|
|
80d196cbfd | ||
|
|
3ce3ccb559 | ||
|
|
a11c6fd8b9 | ||
|
|
a75c5a4cff | ||
|
|
8d504c35bf | ||
|
|
8a07a63b1c | ||
|
|
74fd5de43d | ||
|
|
f9e6722635 | ||
|
|
0bd4250a53 | ||
|
|
4b44aa2180 | ||
|
|
f78984f2ef | ||
|
|
3de311b7f4 | ||
|
|
5192841016 | ||
|
|
07384fd2bb | ||
|
|
a795e7c0c9 | ||
|
|
ebfbd4a37d | ||
|
|
fb933b7d41 | ||
|
|
1c7cb98042 | ||
|
|
fb634cdfc2 | ||
|
|
f60f62792a | ||
|
|
418fde2731 | ||
|
|
53108207be | ||
|
|
3fb3db6f20 | ||
|
|
5a504fa711 | ||
|
|
b4cce22415 | ||
|
|
54c2306637 | ||
|
|
bc8f5f484d | ||
|
|
d00780b574 | ||
|
|
686384ebb7 | ||
|
|
3a85c4d367 | ||
|
|
1b007d2208 | ||
|
|
5a7f669505 | ||
|
|
0c13d9da15 | ||
|
|
58ec26ee89 | ||
|
|
969bcf17c4 | ||
|
|
04d81a0e5c | ||
|
|
a6e99525ac | ||
|
|
7e95b3501d | ||
|
|
ab52acba4b | ||
|
|
07a437c707 | ||
|
|
d56fb1aaa1 | ||
|
|
c046ffbceb | ||
|
|
3435d95c80 | ||
|
|
acaab7a3de | ||
|
|
74ba452025 | ||
|
|
500be2de58 | ||
|
|
5bc0398aaf | ||
|
|
78eba97bf9 | ||
|
|
6350d528a7 | ||
|
|
42eb6b9e01 | ||
|
|
2e2fb68715 | ||
|
|
6fc6355d66 | ||
|
|
48fc93bbdc | ||
|
|
3e941ef959 | ||
|
|
1dc008133c | ||
|
|
2d2ae62176 | ||
|
|
8e9a94613c | ||
|
|
8932133ae7 | ||
|
|
5b8587037d | ||
|
|
e167be6d64 | ||
|
|
34f4109fbd | ||
|
|
fa813bc0d7 | ||
|
|
32006f3a20 | ||
|
|
ff8c961dbb | ||
|
|
e9d5214d1c | ||
|
|
6295b0bd84 | ||
|
|
550f4016dc | ||
|
|
2ae882d801 | ||
|
|
ef81845deb | ||
|
|
d96b681c83 | ||
|
|
59aeaa8476 | ||
|
|
cb2ea300ad | ||
|
|
c38f00fab8 | ||
|
|
0012c76170 | ||
|
|
cfd53bc4aa | ||
|
|
07418140a2 | ||
|
|
c63c259d31 | ||
|
|
50b47adaa3 | ||
|
|
b6ae60cc44 | ||
|
|
d944aa6e79 | ||
|
|
06f05d6cc2 | ||
|
|
0819c6515a | ||
|
|
c7f3e0632b | ||
|
|
58fd6c4ba5 | ||
|
|
aab4a6043a | ||
|
|
a52a4d45c0 | ||
|
|
45bc3f7a09 | ||
|
|
5620858549 | ||
|
|
f2e273b8a2 | ||
|
|
cec1e86b58 | ||
|
|
dcbf289470 | ||
|
|
fdd64d98c8 | ||
|
|
9968992be0 | ||
|
|
f50f9ac894 | ||
|
|
2eca344f0e | ||
|
|
349264830b | ||
|
|
0b5c29022b | ||
|
|
1f1c45a2c0 | ||
|
|
68dc2a70db | ||
|
|
caf1b1cabc | ||
|
|
021c464148 | ||
|
|
e600ead3e9 | ||
|
|
200c10e48c | ||
|
|
e8faff4fe2 | ||
|
|
5cbd4513a4 | ||
|
|
a477c808c7 | ||
|
|
74044f62f4 | ||
|
|
fcd4d94927 | ||
|
|
fac33e46e1 | ||
|
|
b152e53b13 | ||
|
|
1687e3b03f | ||
|
|
c2393685f1 | ||
|
|
fd5f42c2e6 | ||
|
|
bda2d9c3b0 | ||
|
|
c4ecc4db91 | ||
|
|
8ccc51ae57 | ||
|
|
a2b9f3bede | ||
|
|
bd1d1b1a3b | ||
|
|
f1c05f8010 | ||
|
|
f85a77edb5 | ||
|
|
1c7aff5dd9 | ||
|
|
e91f72fe4c | ||
|
|
5a2cae5081 | ||
|
|
6a9dd2029e | ||
|
|
9aac1fb255 | ||
|
|
106b1e7e8d | ||
|
|
836986aa59 | ||
|
|
58d1255357 | ||
|
|
981f712660 | ||
|
|
50dcb8bb75 | ||
|
|
a8a8f01429 | ||
|
|
35c3fe9608 | ||
|
|
f74b9f5fe2 | ||
|
|
de42fe3b04 | ||
|
|
49f835d8cf | ||
|
|
7bc2f41b33 | ||
|
|
a10388b709 | ||
|
|
bd7b5e97cb | ||
|
|
4b525a3967 | ||
|
|
d6739386a0 | ||
|
|
25b790d025 | ||
|
|
db8be91d8b | ||
|
|
c4d4c9c4e4 | ||
|
|
715542ac1c | ||
|
|
0c005a6b01 | ||
|
|
0c45f8d252 | ||
|
|
2dde1242cf | ||
|
|
78cfba0a31 | ||
|
|
8ae682b412 | ||
|
|
333be80f9c | ||
|
|
2efefca737 | ||
|
|
c6bc9fffe9 | ||
|
|
8454c1b52c | ||
|
|
471c0b4993 | ||
|
|
53ed749f45 | ||
|
|
ba084b9987 | ||
|
|
85f28a3f4a | ||
|
|
796072a5a4 | ||
|
|
9390348a65 | ||
|
|
c9c16c7fb8 | ||
|
|
19cd7a4eac | ||
|
|
0315f55fcd | ||
|
|
668e958d3e | ||
|
|
4ace54c4e1 | ||
|
|
89eb13c6cb | ||
|
|
d0ef850035 | ||
|
|
2f8e9f272c | ||
|
|
1af4a3b958 | ||
|
|
1969802c6b | ||
|
|
052883aa55 | ||
|
|
d2918edc14 | ||
|
|
f3da299457 | ||
|
|
e8726b1e22 | ||
|
|
b897a26f42 | ||
|
|
5ec7158b5d | ||
|
|
7d77acd88e | ||
|
|
c0f16603c5 | ||
|
|
34dba0ade8 | ||
|
|
acf7e462ad | ||
|
|
f94b0b54d8 | ||
|
|
806f0d3e6c | ||
|
|
b653572272 | ||
|
|
fa0922d5bb | ||
|
|
95b9f03fb3 | ||
|
|
24e0c944b1 | ||
|
|
148437f716 | ||
|
|
3ddd9962ce | ||
|
|
2634215f12 | ||
|
|
dae34ca8c5 | ||
|
|
03b7ec62ca | ||
|
|
edfcdc466c | ||
|
|
6b3114ad6f | ||
|
|
ba65092926 | ||
|
|
f44138c944 | ||
|
|
c290ce4b91 | ||
|
|
3b34c7b89a | ||
|
|
83e72ec57d | ||
|
|
49893305b4 | ||
|
|
0803c407a9 | ||
|
|
6371135459 | ||
|
|
43af11c46a | ||
|
|
b210858dc5 | ||
|
|
e1f45f9d07 | ||
|
|
dce6b8d72e | ||
|
|
67953bfe2f | ||
|
|
6076656373 | ||
|
|
9a26fa7989 | ||
|
|
d47b83f80b | ||
|
|
b11acad1c9 | ||
|
|
b15efb5201 | ||
|
|
2dfd42f80c | ||
|
|
ce3f79a3bf | ||
|
|
a249d3fe39 | ||
|
|
a6d487de00 | ||
|
|
3720da6386 | ||
|
|
26718e8308 | ||
|
|
f5a196088a | ||
|
|
74f0d08f50 | ||
|
|
046681f4ef | ||
|
|
29531a5e90 | ||
|
|
137a9d6333 | ||
|
|
8115f50d03 | ||
|
|
b75e8ae2bd | ||
|
|
3ad2350c79 | ||
|
|
204f99dd51 | ||
|
|
8df41b069f | ||
|
|
be4256b1d0 | ||
|
|
77a973878c | ||
|
|
7b0d2dfb4a | ||
|
|
79871d2463 | ||
|
|
dce82f4323 | ||
|
|
9e9049307e | ||
|
|
cd34a5d6f3 | ||
|
|
319237910b | ||
|
|
3eed356d70 | ||
|
|
706ff59d70 | ||
|
|
c2eb3f4d36 | ||
|
|
9acc3e0e73 | ||
|
|
94dbaa6822 | ||
|
|
5526ccc696 | ||
|
|
95690e614e | ||
|
|
77f5f8bd1c | ||
|
|
787814ea89 | ||
|
|
67adea5cab | ||
|
|
4226da3d6b | ||
|
|
5270361989 | ||
|
|
a6aa6a4f7b | ||
|
|
1c530be66c | ||
|
|
7c774bc547 | ||
|
|
9954a3c599 | ||
|
|
b91c115ade | ||
|
|
53df9afc2a | ||
|
|
8db45a4e75 | ||
|
|
1c9b1ea91a | ||
|
|
12f2a7cee0 | ||
|
|
3f30bf1e33 | ||
|
|
f968b0abdf | ||
|
|
16ccbf4cdb | ||
|
|
d803fe6123 | ||
|
|
ca15a53fad | ||
|
|
264e5964f6 | ||
|
|
223c611820 | ||
|
|
fbdfa55629 | ||
|
|
73d22cdf54 | ||
|
|
bac81176b2 | ||
|
|
cd2914dbc9 | ||
|
|
cbf3f5d640 | ||
|
|
018e42acad | ||
|
|
482a31b66b | ||
|
|
2b340e8fa4 | ||
|
|
434fac52b7 | ||
|
|
6aacada852 | ||
|
|
7301d7eb67 | ||
|
|
b2d2d5653e | ||
|
|
72fd2a2780 | ||
|
|
9ef031f0f8 | ||
|
|
81b8610dff | ||
|
|
eefd82a574 | ||
|
|
002b5c1dad | ||
|
|
68dab0fe7b | ||
|
|
6d10be8fff | ||
|
|
a23d82e33a | ||
|
|
c7fa9b6e4a | ||
|
|
07bbeafa3b | ||
|
|
06700c1dc4 | ||
|
|
2d252da221 | ||
|
|
2c071a8a2d | ||
|
|
f9187bdfc4 | ||
|
|
25c67cf2aa | ||
|
|
b00a2729e3 | ||
|
|
6c01b86e4c | ||
|
|
d086cf4691 | ||
|
|
5054ed41ac | ||
|
|
e91174e83f | ||
|
|
c9bd25d05c | ||
|
|
f779372154 | ||
|
|
acd9ebbdf8 | ||
|
|
6369cea10e | ||
|
|
2d92719095 | ||
|
|
d4265779ef | ||
|
|
8f2ef6a57d | ||
|
|
6e764942a2 | ||
|
|
11d987549f | ||
|
|
b8c89cd63c | ||
|
|
2f045b20fb | ||
|
|
caa4d33cbd | ||
|
|
a9da7c8fd9 | ||
|
|
b096a2e7e5 | ||
|
|
f9ece0087d | ||
|
|
c76d3b53d9 | ||
|
|
e8277595f5 | ||
|
|
4d3b638a3d | ||
|
|
1d9954d8e9 | ||
|
|
dd7557850e | ||
|
|
c8e1afb14b | ||
|
|
6d162eeff9 | ||
|
|
746d4037da | ||
|
|
1237e02f7c | ||
|
|
7da3d4ba50 | ||
|
|
c22b93734e | ||
|
|
8853315dcc | ||
|
|
5aaffaaecb | ||
|
|
389a8d47a3 | ||
|
|
a355769416 | ||
|
|
1a8c9216d6 | ||
|
|
81316ef644 | ||
|
|
4d4d0de356 | ||
|
|
b85adbc40a | ||
|
|
aefbd66317 | ||
|
|
d875cca69d | ||
|
|
0e902fe949 | ||
|
|
582eb57a09 | ||
|
|
177f1eca06 | ||
|
|
57f46ded83 | ||
|
|
aa245c2d06 | ||
|
|
e836db1ead | ||
|
|
5420347d24 | ||
|
|
9e2637d65f | ||
|
|
c6046597ed | ||
|
|
a46c8fe914 | ||
|
|
f822816cdb | ||
|
|
f3bf9b4bbb | ||
|
|
9f02899261 | ||
|
|
75f3e1fb03 | ||
|
|
9fbfa7c1f5 | ||
|
|
d5aef85bf2 | ||
|
|
88b32e4b18 | ||
|
|
e425e3ffd3 | ||
|
|
355483fd86 | ||
|
|
672d8474b9 | ||
|
|
73e4d38670 | ||
|
|
561c15bbe8 | ||
|
|
b93aa723cb | ||
|
|
636943c715 | ||
|
|
0a6a67da85 | ||
|
|
e9ffd366dd | ||
|
|
4be0b3f556 | ||
|
|
a0bfad6d6e | ||
|
|
bb1f17f5af | ||
|
|
95bc2ee241 | ||
|
|
16a90e799c | ||
|
|
4c2f84b211 | ||
|
|
b799635fbb | ||
|
|
bc145952d4 | ||
|
|
2c5701917d | ||
|
|
ed7b840fea | ||
|
|
23372e29cd | ||
|
|
fb569b0483 | ||
|
|
e2b5638ca0 | ||
|
|
8f5a1dce3e | ||
|
|
6b0f5da113 | ||
|
|
5159b6d085 | ||
|
|
03d97c3872 | ||
|
|
41c5a0ddf5 | ||
|
|
19165a40d2 | ||
|
|
d1ebcb59f1 | ||
|
|
31344128a0 | ||
|
|
86ecc2a234 | ||
|
|
d1e8ac7ba5 | ||
|
|
efe208fef5 | ||
|
|
7b40e99aec | ||
|
|
06706aab9a | ||
|
|
0318af5a33 | ||
|
|
995dcfc6ae | ||
|
|
2236cc8bf7 | ||
|
|
7bb354117b | ||
|
|
18b00b5d8d | ||
|
|
dbe193ad17 | ||
|
|
e7424222db | ||
|
|
d2a162e3ee | ||
|
|
da14750396 | ||
|
|
8fe72dcb74 | ||
|
|
d35f5fe498 | ||
|
|
677bd9b657 | ||
|
|
a347d276bd | ||
|
|
9e1253ada1 | ||
|
|
244e79f575 | ||
|
|
b4e6a32b4b | ||
|
|
023cd8f4cd | ||
|
|
10d24e64cd | ||
|
|
37e191a75d | ||
|
|
01a5fe3c51 | ||
|
|
9e3339b4f1 | ||
|
|
b06e34f144 | ||
|
|
710616f118 | ||
|
|
ddf042cab1 | ||
|
|
687e8d12be | ||
|
|
01f755ecf9 | ||
|
|
8094e6fdef | ||
|
|
061efa1822 | ||
|
|
9a7472218e | ||
|
|
7dcf4a5147 | ||
|
|
306a80cf57 | ||
|
|
d0cd5af419 | ||
|
|
afbfc1d370 | ||
|
|
a9a1a8fb3c | ||
|
|
85ddc0db33 | ||
|
|
fddc2aa8fa | ||
|
|
be3a379d10 | ||
|
|
d0daff180e | ||
|
|
be36480a64 | ||
|
|
9f52a64a6a | ||
|
|
52511af8e4 | ||
|
|
ddb6bd795c | ||
|
|
271660a284 | ||
|
|
0b0f7db534 | ||
|
|
5a7b377f6f | ||
|
|
654d2b9910 | ||
|
|
829a8c4381 | ||
|
|
0603e29c46 | ||
|
|
5807562b56 | ||
|
|
985c6e7cc9 | ||
|
|
0d13e16fed | ||
|
|
91d135e069 | ||
|
|
3e1e07e8c1 | ||
|
|
6c4c761408 | ||
|
|
abfb1791f1 | ||
|
|
7ce8c4c649 | ||
|
|
2ddca366f2 | ||
|
|
9a6ac6e3e6 | ||
|
|
cc3e8705bd | ||
|
|
809a5b84e7 | ||
|
|
06ae2a6c50 | ||
|
|
93517aa6f8 | ||
|
|
5f0f3705c0 | ||
|
|
70ae18c3a8 | ||
|
|
6aa763a1ae | ||
|
|
ebfb8c8c5e | ||
|
|
30788e1a70 | ||
|
|
27947c6746 | ||
|
|
6924b7bf4c | ||
|
|
fa8cd96108 | ||
|
|
dd1e425d02 | ||
|
|
7f2027d7f2 | ||
|
|
48f5a9a18c | ||
|
|
8843188b84 | ||
|
|
087c461762 | ||
|
|
d579c1718c | ||
|
|
4c5f667504 | ||
|
|
4c4c95198b | ||
|
|
5ce1526a06 | ||
|
|
d70c3d6189 | ||
|
|
74e6c1479e | ||
|
|
9a0c9768ad | ||
|
|
6884798404 | ||
|
|
c4487b73c4 | ||
|
|
32c3f09eb4 | ||
|
|
d4dc133e20 | ||
|
|
fc5153af3e | ||
|
|
2997f4d251 | ||
|
|
e407d423d4 | ||
|
|
35795c79c3 | ||
|
|
fd8d888ddb | ||
|
|
06f56411dd | ||
|
|
e4f197b709 | ||
|
|
13406175c1 | ||
|
|
20117c51a2 | ||
|
|
f0c54490ed | ||
|
|
95f726fb31 | ||
|
|
ba391bc2ed | ||
|
|
c487591437 | ||
|
|
c582c8d206 | ||
|
|
1a0f6f6e39 | ||
|
|
6981543db6 | ||
|
|
722084fbd3 | ||
|
|
a01a0d1039 | ||
|
|
8abc7575cd | ||
|
|
c9a411e341 | ||
|
|
b02a9f9769 | ||
|
|
a0fa652449 | ||
|
|
2eef535b4b | ||
|
|
61870a275f | ||
|
|
088e8248d3 | ||
|
|
da4a9dadd5 | ||
|
|
02bc7314f4 | ||
|
|
6fb8d67825 | ||
|
|
1a41a9f2c7 | ||
|
|
040a18e6f8 | ||
|
|
ec911981c2 | ||
|
|
f6a7564ec8 | ||
|
|
2eb57e6288 | ||
|
|
94ba5181fc | ||
|
|
1d5b090579 | ||
|
|
ef0f7c0c09 | ||
|
|
e60ceefea9 | ||
|
|
ed6b5bc279 | ||
|
|
d3ef39a58f | ||
|
|
07e32be5ce | ||
|
|
ed0b31d072 | ||
|
|
fcc6991d62 | ||
|
|
c09428acca | ||
|
|
931ef9482b | ||
|
|
772541afab | ||
|
|
2090a13dcd | ||
|
|
31b4f03f96 | ||
|
|
7793012409 | ||
|
|
566c2bc1fb | ||
|
|
99efeb98f8 | ||
|
|
836ee74e57 | ||
|
|
06689ed726 | ||
|
|
817cc1e567 | ||
|
|
8fa0fe65ba | ||
|
|
1d81333685 | ||
|
|
1bddf1147b | ||
|
|
63fa475913 | ||
|
|
d637a9c302 | ||
|
|
3c3189caa6 | ||
|
|
0d4a006536 | ||
|
|
0475eb6ef7 | ||
|
|
0d1b60ad63 | ||
|
|
78a0f3ca37 | ||
|
|
2c83eac36f | ||
|
|
42913e2c37 | ||
|
|
54daa0da23 | ||
|
|
0435089eba | ||
|
|
39abc4e973 | ||
|
|
cefe2d5bcc | ||
|
|
ed728f57e0 | ||
|
|
6ffea2225d | ||
|
|
64185cc2bc | ||
|
|
990ff153c0 | ||
|
|
47dcc940c0 | ||
|
|
8d60ae2c7e | ||
|
|
19492650d4 | ||
|
|
36ae14bccf | ||
|
|
45e71ecba0 | ||
|
|
e432e98413 | ||
|
|
656237e167 | ||
|
|
5dbf6b5127 | ||
|
|
c9e4da3ff5 | ||
|
|
cfd4781eb4 | ||
|
|
986725519f | ||
|
|
0393ab524c | ||
|
|
3f3cfedffa | ||
|
|
e9ea698130 | ||
|
|
a6adcdafa9 | ||
|
|
7c37086dd6 | ||
|
|
2048e9e136 | ||
|
|
cc054d71fe | ||
|
|
0bbf343348 | ||
|
|
8248b71153 | ||
|
|
9811809f6a | ||
|
|
237a14858a | ||
|
|
59c3d4bcfe | ||
|
|
7612cc84d2 | ||
|
|
4aa91bc420 | ||
|
|
c801a8c553 | ||
|
|
5626f598ce | ||
|
|
7d0da8b578 | ||
|
|
eb87fc9215 | ||
|
|
b22a9781a2 | ||
|
|
ada40960bd | ||
|
|
83ead36fce | ||
|
|
05a5f21c3d | ||
|
|
e7a2501fe8 | ||
|
|
a36328dbfc | ||
|
|
cab5641d95 | ||
|
|
b83894abd6 | ||
|
|
8e588ae146 | ||
|
|
83815f567d | ||
|
|
7db91c68be | ||
|
|
109115c13b | ||
|
|
11e0402396 | ||
|
|
fd94105483 | ||
|
|
96e8142540 | ||
|
|
9615138202 | ||
|
|
9900b215cc | ||
|
|
d5ea224e11 | ||
|
|
024d6ee7c3 | ||
|
|
f653b00258 | ||
|
|
ff1ee4ca64 | ||
|
|
830aa250e1 | ||
|
|
f0bbc3c7d8 | ||
|
|
994b4eef17 | ||
|
|
f905812afa | ||
|
|
d68d201722 | ||
|
|
da209e89a7 | ||
|
|
7940dbc78b | ||
|
|
4d22b4252f | ||
|
|
b3efd1e47b | ||
|
|
2d39d6602c | ||
|
|
dfcab2b6d5 | ||
|
|
40c5263927 | ||
|
|
bf26e37e0e | ||
|
|
e154e7a0fb | ||
|
|
b28ebb5d20 | ||
|
|
5840f88251 | ||
|
|
2c2968473a | ||
|
|
8f1f48b7d0 | ||
|
|
536e8b71bf | ||
|
|
acc43c39af | ||
|
|
eae1b6a3de | ||
|
|
31cc61478f | ||
|
|
3095c1e150 | ||
|
|
e1d5da5bd9 | ||
|
|
5f818b7349 | ||
|
|
0aac79f8fa | ||
|
|
1e93347a26 | ||
|
|
18867a4c84 | ||
|
|
3b97c7bdec | ||
|
|
203e6bc6b2 | ||
|
|
e27753e46e | ||
|
|
11fbef4bf0 | ||
|
|
c4e6ad1ec7 | ||
|
|
263a3f1983 | ||
|
|
8acaea0fbe | ||
|
|
bd6adfaec6 | ||
|
|
4b4a5a4b93 | ||
|
|
b098d84557 | ||
|
|
b937f9b762 | ||
|
|
55f3e07bd4 | ||
|
|
2780623076 | ||
|
|
75a342f96e | ||
|
|
729cd54401 | ||
|
|
a023f51971 | ||
|
|
5076eb9215 | ||
|
|
7edd0cd14c | ||
|
|
7ce4738d8a | ||
|
|
7287e0259c | ||
|
|
d86de68b40 | ||
|
|
4ba107a765 | ||
|
|
187b016d09 | ||
|
|
7010f5afad | ||
|
|
48b73fa12f | ||
|
|
1ecd0d7ca4 | ||
|
|
6faaae0c5f | ||
|
|
e4ef65be76 | ||
|
|
39c661d408 | ||
|
|
91a48d6a43 | ||
|
|
123f0fa185 | ||
|
|
ba3dffecbf | ||
|
|
8735e5675c | ||
|
|
3f5e06a0f8 | ||
|
|
ba40a40b73 | ||
|
|
b3732e7fb9 | ||
|
|
104776ee84 | ||
|
|
01e781e546 | ||
|
|
e77c16b55a | ||
|
|
987bbee1db | ||
|
|
74d2fe1baa | ||
|
|
98e63d5561 | ||
|
|
059f13fc9d | ||
|
|
ebd27b46af | ||
|
|
ca8d814918 | ||
|
|
0aeeaac361 | ||
|
|
28ed8a5742 | ||
|
|
f749be1490 | ||
|
|
693bce1b10 | ||
|
|
4f97e077db | ||
|
|
c883e79884 | ||
|
|
a613501ff2 | ||
|
|
a054e2514a | ||
|
|
c49fe26da7 | ||
|
|
75afdc6306 | ||
|
|
f02beaf075 | ||
|
|
8bcc7e88f0 | ||
|
|
0adbd720bf | ||
|
|
d93a7f2e02 | ||
|
|
88d7ac04bf | ||
|
|
1f422af1c8 | ||
|
|
53168d54d8 | ||
|
|
b0ec945dbb | ||
|
|
48ef6e5a6f | ||
|
|
8d1adaaef3 | ||
|
|
dd8c0d1e9e | ||
|
|
57b79aa852 | ||
|
|
2f883410d2 | ||
|
|
6fa61380b2 | ||
|
|
47b61c0cea | ||
|
|
d739ac830f | ||
|
|
26024fedc7 | ||
|
|
a376b697c0 | ||
|
|
bc2574680d | ||
|
|
f194b41435 | ||
|
|
350f7da55d | ||
|
|
36f5f78f46 | ||
|
|
55fe5b0b41 | ||
|
|
7d1a5c00a0 | ||
|
|
036061664e | ||
|
|
5b1b40ce93 | ||
|
|
a8d9fdce3c | ||
|
|
700382cba4 | ||
|
|
9698abbfd5 | ||
|
|
5bfcf5c917 | ||
|
|
8eb7d47072 | ||
|
|
ab61c87701 | ||
|
|
c1e6157847 | ||
|
|
4c849539fc | ||
|
|
9c2a630055 | ||
|
|
0e1ddf9715 | ||
|
|
54da1a4155 | ||
|
|
7141e2ed70 | ||
|
|
c9e5048015 | ||
|
|
4e077b053c | ||
|
|
f973aef80c | ||
|
|
a43bb1bb40 | ||
|
|
d86123195c | ||
|
|
91ffd10192 | ||
|
|
642c7824a7 | ||
|
|
149279f3d5 | ||
|
|
275214920f | ||
|
|
0124899759 | ||
|
|
033136cb9a | ||
|
|
05e08e0ac7 | ||
|
|
226cb89d97 | ||
|
|
3007c0ec4f | ||
|
|
3fa1ac9c79 | ||
|
|
bb2ccfddd9 | ||
|
|
99fd126219 | ||
|
|
15b8c8f4c5 | ||
|
|
4243885246 | ||
|
|
5bc5c5dc1b | ||
|
|
db4f49901e | ||
|
|
73a00c89ff | ||
|
|
8a614dabc0 | ||
|
|
c95cf15731 | ||
|
|
e7ce902f9d | ||
|
|
d421c7b665 | ||
|
|
1abc68ccf4 | ||
|
|
575b15e5fa | ||
|
|
a8c8a358d0 | ||
|
|
cd2ca137c0 | ||
|
|
0660867a16 | ||
|
|
b1200140b8 | ||
|
|
d10b57b317 | ||
|
|
42bf566fff | ||
|
|
0bb2fabc6c | ||
|
|
ee704f8ef3 | ||
|
|
4aad3b7933 | ||
|
|
6091373b53 | ||
|
|
3879120967 | ||
|
|
465669f650 | ||
|
|
ea615e3a26 | ||
|
|
d3349aa4d1 | ||
|
|
73207decfd | ||
|
|
eda6e560c3 | ||
|
|
95de823b72 | ||
|
|
9f85efffd5 | ||
|
|
b5841c8a8b | ||
|
|
309f868a21 | ||
|
|
6c903d4a2f | ||
|
|
c3aa9a5d4c | ||
|
|
4fb55e1684 | ||
|
|
91bfb481c1 | ||
|
|
201ba109c3 | ||
|
|
d3f965d493 | ||
|
|
f832d7325b | ||
|
|
b1d1bd32c3 | ||
|
|
df6d4de6fd | ||
|
|
461a893ee4 | ||
|
|
97f7c90092 | ||
|
|
ea3043cdcb | ||
|
|
04dffcc4ae | ||
|
|
3a07360b6e | ||
|
|
b97d6f71b1 | ||
|
|
4915902e04 | ||
|
|
d87a4c87cc | ||
|
|
e56755fd67 | ||
|
|
2862c2034b | ||
|
|
53185eaa9e | ||
|
|
b83ecc3e6e | ||
|
|
04fdd94201 | ||
|
|
48ec51d166 | ||
|
|
3260362436 | ||
|
|
5f60671d12 | ||
|
|
69d77f6e9d | ||
|
|
1af9c11bdd | ||
|
|
57c115e60a | ||
|
|
96b4d2f391 | ||
|
|
0f649aae8b | ||
|
|
f491db232b | ||
|
|
9a24340bd4 | ||
|
|
39b756cf55 | ||
|
|
9ca2ae7fc5 | ||
|
|
f3139d26c8 | ||
|
|
6f20a1fc68 | ||
|
|
243b961cbe | ||
|
|
5748744134 | ||
|
|
31556e1ac0 | ||
|
|
0159649d0a | ||
|
|
cf9d920e4a | ||
|
|
7d46dfe012 | ||
|
|
eabb1ce881 |
19
.dockerignore
Normal file
19
.dockerignore
Normal file
@@ -0,0 +1,19 @@
|
||||
// integration tests are not needed in docker
|
||||
// ignoring it let us speed up the integration test
|
||||
// development
|
||||
integration_test.go
|
||||
integration_test/
|
||||
!integration_test/etc_embedded_derp/tls/server.crt
|
||||
|
||||
Dockerfile*
|
||||
docker-compose*
|
||||
.dockerignore
|
||||
.goreleaser.yml
|
||||
.git
|
||||
.github
|
||||
.gitignore
|
||||
README.md
|
||||
LICENSE
|
||||
.vscode
|
||||
|
||||
*.sock
|
||||
10
.github/CODEOWNERS
vendored
Normal file
10
.github/CODEOWNERS
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
* @juanfont @kradalby
|
||||
|
||||
*.md @ohdearaugustin
|
||||
*.yml @ohdearaugustin
|
||||
*.yaml @ohdearaugustin
|
||||
Dockerfile* @ohdearaugustin
|
||||
.goreleaser.yaml @ohdearaugustin
|
||||
/docs/ @ohdearaugustin
|
||||
/.github/workflows/ @ohdearaugustin
|
||||
/.github/renovate.json @ohdearaugustin
|
||||
2
.github/FUNDING.yml
vendored
Normal file
2
.github/FUNDING.yml
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
ko_fi: kradalby
|
||||
github: [kradalby]
|
||||
28
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
28
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
@@ -0,0 +1,28 @@
|
||||
---
|
||||
name: "Bug report"
|
||||
about: "Create a bug report to help us improve"
|
||||
title: ""
|
||||
labels: ["bug"]
|
||||
assignees: ""
|
||||
---
|
||||
|
||||
**Bug description**
|
||||
|
||||
<!-- A clear and concise description of what the bug is. Describe the expected bahavior
|
||||
and how it is currently different. If you are unsure if it is a bug, consider discussing
|
||||
it on our Discord server first. -->
|
||||
|
||||
**To Reproduce**
|
||||
|
||||
<!-- Steps to reproduce the behavior. -->
|
||||
|
||||
**Context info**
|
||||
|
||||
<!-- Please add relevant information about your system. For example:
|
||||
- Version of headscale used
|
||||
- Version of tailscale client
|
||||
- OS (e.g. Linux, Mac, Cygwin, WSL, etc.) and version
|
||||
- Kernel version
|
||||
- The relevant config parameters you used
|
||||
- Log output
|
||||
-->
|
||||
11
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
11
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
# Issues must have some content
|
||||
blank_issues_enabled: false
|
||||
|
||||
# Contact links
|
||||
contact_links:
|
||||
- name: "headscale usage documentation"
|
||||
url: "https://github.com/juanfont/headscale/blob/main/docs"
|
||||
about: "Find documentation about how to configure and run headscale."
|
||||
- name: "headscale Discord community"
|
||||
url: "https://discord.com/invite/XcQxk2VHjx"
|
||||
about: "Please ask and answer questions about usage of headscale here."
|
||||
15
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
15
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
@@ -0,0 +1,15 @@
|
||||
---
|
||||
name: "Feature request"
|
||||
about: "Suggest an idea for headscale"
|
||||
title: ""
|
||||
labels: ["enhancement"]
|
||||
assignees: ""
|
||||
---
|
||||
|
||||
**Feature request**
|
||||
|
||||
<!-- A clear and precise description of what new or changed feature you want. -->
|
||||
|
||||
<!-- Please include the reason, why you would need the feature. E.g. what problem
|
||||
does it solve? Or which workflow is currently frustrating and will be improved by
|
||||
this? -->
|
||||
28
.github/ISSUE_TEMPLATE/other_issue.md
vendored
Normal file
28
.github/ISSUE_TEMPLATE/other_issue.md
vendored
Normal file
@@ -0,0 +1,28 @@
|
||||
---
|
||||
name: "Other issue"
|
||||
about: "Report a different issue"
|
||||
title: ""
|
||||
labels: ["bug"]
|
||||
assignees: ""
|
||||
---
|
||||
|
||||
<!-- If you have a question, please consider using our Discord for asking questions -->
|
||||
|
||||
**Issue description**
|
||||
|
||||
<!-- Please add your issue description. -->
|
||||
|
||||
**To Reproduce**
|
||||
|
||||
<!-- Steps to reproduce the behavior. -->
|
||||
|
||||
**Context info**
|
||||
|
||||
<!-- Please add relevant information about your system. For example:
|
||||
- Version of headscale used
|
||||
- Version of tailscale client
|
||||
- OS (e.g. Linux, Mac, Cygwin, WSL, etc.) and version
|
||||
- Kernel version
|
||||
- The relevant config parameters you used
|
||||
- Log output
|
||||
-->
|
||||
10
.github/pull_request_template.md
vendored
Normal file
10
.github/pull_request_template.md
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
<!-- Please tick if the following things apply. You… -->
|
||||
|
||||
- [ ] read the [CONTRIBUTING guidelines](README.md#user-content-contributing)
|
||||
- [ ] raised a GitHub issue or discussed it on the projects chat beforehand
|
||||
- [ ] added unit tests
|
||||
- [ ] added integration tests
|
||||
- [ ] updated documentation if needed
|
||||
- [ ] updated CHANGELOG.md
|
||||
|
||||
<!-- If applicable, please reference the issue using `Fixes #XXX` and add tests to cover your new code. -->
|
||||
38
.github/renovate.json
vendored
Normal file
38
.github/renovate.json
vendored
Normal file
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"baseBranches": ["main"],
|
||||
"username": "renovate-release",
|
||||
"gitAuthor": "Renovate Bot <bot@renovateapp.com>",
|
||||
"branchPrefix": "renovateaction/",
|
||||
"onboarding": false,
|
||||
"extends": ["config:base", ":rebaseStalePrs"],
|
||||
"ignorePresets": [":prHourlyLimit2"],
|
||||
"enabledManagers": ["dockerfile", "gomod", "github-actions","regex" ],
|
||||
"includeForks": true,
|
||||
"repositories": ["juanfont/headscale"],
|
||||
"platform": "github",
|
||||
"packageRules": [
|
||||
{
|
||||
"matchDatasources": ["go"],
|
||||
"groupName": "Go modules",
|
||||
"groupSlug": "gomod",
|
||||
"separateMajorMinor": false
|
||||
},
|
||||
{
|
||||
"matchDatasources": ["docker"],
|
||||
"groupName": "Dockerfiles",
|
||||
"groupSlug": "dockerfiles"
|
||||
}
|
||||
],
|
||||
"regexManagers": [
|
||||
{
|
||||
"fileMatch": [
|
||||
".github/workflows/.*.yml$"
|
||||
],
|
||||
"matchStrings": [
|
||||
"\\s*go-version:\\s*\"?(?<currentValue>.*?)\"?\\n"
|
||||
],
|
||||
"datasourceTemplate": "golang-version",
|
||||
"depNameTemplate": "actions/go-version"
|
||||
}
|
||||
]
|
||||
}
|
||||
51
.github/workflows/build.yml
vendored
Normal file
51
.github/workflows/build.yml
vendored
Normal file
@@ -0,0 +1,51 @@
|
||||
name: Build
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
with:
|
||||
fetch-depth: 2
|
||||
|
||||
- name: Get changed files
|
||||
id: changed-files
|
||||
uses: tj-actions/changed-files@v14.1
|
||||
with:
|
||||
files: |
|
||||
go.*
|
||||
**/*.go
|
||||
integration_test/
|
||||
config-example.yaml
|
||||
|
||||
- name: Setup Go
|
||||
if: steps.changed-files.outputs.any_changed == 'true'
|
||||
uses: actions/setup-go@v2
|
||||
with:
|
||||
go-version: "1.18.0"
|
||||
|
||||
- name: Install dependencies
|
||||
if: steps.changed-files.outputs.any_changed == 'true'
|
||||
run: |
|
||||
go version
|
||||
sudo apt update
|
||||
sudo apt install -y make
|
||||
|
||||
- name: Run build
|
||||
if: steps.changed-files.outputs.any_changed == 'true'
|
||||
run: make build
|
||||
|
||||
- uses: actions/upload-artifact@v2
|
||||
if: steps.changed-files.outputs.any_changed == 'true'
|
||||
with:
|
||||
name: headscale-linux
|
||||
path: headscale
|
||||
35
.github/workflows/contributors.yml
vendored
Normal file
35
.github/workflows/contributors.yml
vendored
Normal file
@@ -0,0 +1,35 @@
|
||||
name: Contributors
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch:
|
||||
jobs:
|
||||
add-contributors:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Delete upstream contributor branch
|
||||
# Allow continue on failure to account for when the
|
||||
# upstream branch is deleted or does not exist.
|
||||
continue-on-error: true
|
||||
run: git push origin --delete update-contributors
|
||||
- name: Create up-to-date contributors branch
|
||||
run: git checkout -B update-contributors
|
||||
- name: Push empty contributors branch
|
||||
run: git push origin update-contributors
|
||||
- name: Switch back to main
|
||||
run: git checkout main
|
||||
- uses: BobAnkh/add-contributors@v0.2.2
|
||||
with:
|
||||
CONTRIBUTOR: "## Contributors"
|
||||
COLUMN_PER_ROW: "6"
|
||||
ACCESS_TOKEN: ${{secrets.GITHUB_TOKEN}}
|
||||
IMG_WIDTH: "100"
|
||||
FONT_SIZE: "14"
|
||||
PATH: "/README.md"
|
||||
COMMIT_MESSAGE: "docs(README): update contributors"
|
||||
AVATAR_SHAPE: "round"
|
||||
BRANCH: "update-contributors"
|
||||
PULL_REQUEST: "main"
|
||||
74
.github/workflows/lint.yml
vendored
Normal file
74
.github/workflows/lint.yml
vendored
Normal file
@@ -0,0 +1,74 @@
|
||||
---
|
||||
name: CI
|
||||
|
||||
on: [push, pull_request]
|
||||
|
||||
jobs:
|
||||
golangci-lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
with:
|
||||
fetch-depth: 2
|
||||
|
||||
- name: Get changed files
|
||||
id: changed-files
|
||||
uses: tj-actions/changed-files@v14.1
|
||||
with:
|
||||
files: |
|
||||
go.*
|
||||
**/*.go
|
||||
integration_test/
|
||||
config-example.yaml
|
||||
|
||||
- name: golangci-lint
|
||||
if: steps.changed-files.outputs.any_changed == 'true'
|
||||
uses: golangci/golangci-lint-action@v2
|
||||
with:
|
||||
version: latest
|
||||
|
||||
# Only block PRs on new problems.
|
||||
# If this is not enabled, we will end up having PRs
|
||||
# blocked because new linters has appared and other
|
||||
# parts of the code is affected.
|
||||
only-new-issues: true
|
||||
|
||||
prettier-lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
with:
|
||||
fetch-depth: 2
|
||||
|
||||
- name: Get changed files
|
||||
id: changed-files
|
||||
uses: tj-actions/changed-files@v14.1
|
||||
with:
|
||||
files: |
|
||||
**/*.md
|
||||
**/*.yml
|
||||
**/*.yaml
|
||||
**/*.ts
|
||||
**/*.js
|
||||
**/*.sass
|
||||
**/*.css
|
||||
**/*.scss
|
||||
**/*.html
|
||||
|
||||
- name: Prettify code
|
||||
if: steps.changed-files.outputs.any_changed == 'true'
|
||||
uses: creyD/prettier_action@v4.0
|
||||
with:
|
||||
prettier_options: >-
|
||||
--check **/*.{ts,js,md,yaml,yml,sass,css,scss,html}
|
||||
only_changed: false
|
||||
dry: true
|
||||
|
||||
proto-lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: bufbuild/buf-setup-action@v0.7.0
|
||||
- uses: bufbuild/buf-lint-action@v1
|
||||
with:
|
||||
input: "proto"
|
||||
213
.github/workflows/release.yml
vendored
213
.github/workflows/release.yml
vendored
@@ -1,30 +1,223 @@
|
||||
name: goreleaser
|
||||
---
|
||||
name: release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "*" # triggers only if push new tag version
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
goreleaser:
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-18.04 # due to CGO we need to user an older version
|
||||
steps:
|
||||
-
|
||||
name: Checkout
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
-
|
||||
name: Set up Go
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v2
|
||||
with:
|
||||
go-version: 1.16
|
||||
-
|
||||
name: Run GoReleaser
|
||||
go-version: 1.18.0
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
sudo apt update
|
||||
sudo apt install -y gcc-aarch64-linux-gnu
|
||||
- name: Run GoReleaser
|
||||
uses: goreleaser/goreleaser-action@v2
|
||||
with:
|
||||
distribution: goreleaser
|
||||
version: latest
|
||||
args: release --rm-dist
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
docker-release:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v1
|
||||
- name: Set up QEMU for multiple platforms
|
||||
uses: docker/setup-qemu-action@master
|
||||
with:
|
||||
platforms: arm64,amd64
|
||||
- name: Cache Docker layers
|
||||
uses: actions/cache@v2
|
||||
with:
|
||||
path: /tmp/.buildx-cache
|
||||
key: ${{ runner.os }}-buildx-${{ github.sha }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-buildx-
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v3
|
||||
with:
|
||||
# list of Docker images to use as base name for tags
|
||||
images: |
|
||||
${{ secrets.DOCKERHUB_USERNAME }}/headscale
|
||||
ghcr.io/${{ github.repository_owner }}/headscale
|
||||
tags: |
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=semver,pattern={{major}}
|
||||
type=raw,value=latest
|
||||
type=sha
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@v1
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
- name: Login to GHCR
|
||||
uses: docker/login-action@v1
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Build and push
|
||||
id: docker_build
|
||||
uses: docker/build-push-action@v2
|
||||
with:
|
||||
push: true
|
||||
context: .
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
platforms: linux/amd64,linux/arm64
|
||||
cache-from: type=local,src=/tmp/.buildx-cache
|
||||
cache-to: type=local,dest=/tmp/.buildx-cache-new
|
||||
- name: Prepare cache for next build
|
||||
run: |
|
||||
rm -rf /tmp/.buildx-cache
|
||||
mv /tmp/.buildx-cache-new /tmp/.buildx-cache
|
||||
|
||||
docker-debug-release:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v1
|
||||
- name: Set up QEMU for multiple platforms
|
||||
uses: docker/setup-qemu-action@master
|
||||
with:
|
||||
platforms: arm64,amd64
|
||||
- name: Cache Docker layers
|
||||
uses: actions/cache@v2
|
||||
with:
|
||||
path: /tmp/.buildx-cache-debug
|
||||
key: ${{ runner.os }}-buildx-debug-${{ github.sha }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-buildx-debug-
|
||||
- name: Docker meta
|
||||
id: meta-debug
|
||||
uses: docker/metadata-action@v3
|
||||
with:
|
||||
# list of Docker images to use as base name for tags
|
||||
images: |
|
||||
${{ secrets.DOCKERHUB_USERNAME }}/headscale
|
||||
ghcr.io/${{ github.repository_owner }}/headscale
|
||||
flavor: |
|
||||
latest=false
|
||||
tags: |
|
||||
type=semver,pattern={{version}}-debug
|
||||
type=semver,pattern={{major}}.{{minor}}-debug
|
||||
type=semver,pattern={{major}}-debug
|
||||
type=raw,value=latest-debug
|
||||
type=sha,suffix=-debug
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@v1
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
- name: Login to GHCR
|
||||
uses: docker/login-action@v1
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Build and push
|
||||
id: docker_build
|
||||
uses: docker/build-push-action@v2
|
||||
with:
|
||||
push: true
|
||||
context: .
|
||||
file: Dockerfile.debug
|
||||
tags: ${{ steps.meta-debug.outputs.tags }}
|
||||
labels: ${{ steps.meta-debug.outputs.labels }}
|
||||
platforms: linux/amd64,linux/arm64
|
||||
cache-from: type=local,src=/tmp/.buildx-cache-debug
|
||||
cache-to: type=local,dest=/tmp/.buildx-cache-debug-new
|
||||
- name: Prepare cache for next build
|
||||
run: |
|
||||
rm -rf /tmp/.buildx-cache-debug
|
||||
mv /tmp/.buildx-cache-debug-new /tmp/.buildx-cache-debug
|
||||
|
||||
docker-alpine-release:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v1
|
||||
- name: Set up QEMU for multiple platforms
|
||||
uses: docker/setup-qemu-action@master
|
||||
with:
|
||||
platforms: arm64,amd64
|
||||
- name: Cache Docker layers
|
||||
uses: actions/cache@v2
|
||||
with:
|
||||
path: /tmp/.buildx-cache-alpine
|
||||
key: ${{ runner.os }}-buildx-alpine-${{ github.sha }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-buildx-alpine-
|
||||
- name: Docker meta
|
||||
id: meta-alpine
|
||||
uses: docker/metadata-action@v3
|
||||
with:
|
||||
# list of Docker images to use as base name for tags
|
||||
images: |
|
||||
${{ secrets.DOCKERHUB_USERNAME }}/headscale
|
||||
ghcr.io/${{ github.repository_owner }}/headscale
|
||||
flavor: |
|
||||
latest=false
|
||||
tags: |
|
||||
type=semver,pattern={{version}}-alpine
|
||||
type=semver,pattern={{major}}.{{minor}}-alpine
|
||||
type=semver,pattern={{major}}-alpine
|
||||
type=raw,value=latest-alpine
|
||||
type=sha,suffix=-alpine
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@v1
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
- name: Login to GHCR
|
||||
uses: docker/login-action@v1
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Build and push
|
||||
id: docker_build
|
||||
uses: docker/build-push-action@v2
|
||||
with:
|
||||
push: true
|
||||
context: .
|
||||
file: Dockerfile.alpine
|
||||
tags: ${{ steps.meta-alpine.outputs.tags }}
|
||||
labels: ${{ steps.meta-alpine.outputs.labels }}
|
||||
platforms: linux/amd64,linux/arm64
|
||||
cache-from: type=local,src=/tmp/.buildx-cache-alpine
|
||||
cache-to: type=local,dest=/tmp/.buildx-cache-alpine-new
|
||||
- name: Prepare cache for next build
|
||||
run: |
|
||||
rm -rf /tmp/.buildx-cache-alpine
|
||||
mv /tmp/.buildx-cache-alpine-new /tmp/.buildx-cache-alpine
|
||||
|
||||
27
.github/workflows/renovatebot.yml
vendored
Normal file
27
.github/workflows/renovatebot.yml
vendored
Normal file
@@ -0,0 +1,27 @@
|
||||
---
|
||||
name: Renovate
|
||||
on:
|
||||
schedule:
|
||||
- cron: "* * 5,20 * *" # Every 5th and 20th of the month
|
||||
workflow_dispatch:
|
||||
jobs:
|
||||
renovate:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Get token
|
||||
id: get_token
|
||||
uses: machine-learning-apps/actions-app-token@master
|
||||
with:
|
||||
APP_PEM: ${{ secrets.RENOVATEBOT_SECRET }}
|
||||
APP_ID: ${{ secrets.RENOVATEBOT_APP_ID }}
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v2.0.0
|
||||
|
||||
- name: Self-hosted Renovate
|
||||
uses: renovatebot/github-action@v31.81.3
|
||||
with:
|
||||
configurationFile: .github/renovate.json
|
||||
token: "x-access-token:${{ steps.get_token.outputs.app_token }}"
|
||||
# env:
|
||||
# LOG_LEVEL: "debug"
|
||||
32
.github/workflows/test-integration.yml
vendored
Normal file
32
.github/workflows/test-integration.yml
vendored
Normal file
@@ -0,0 +1,32 @@
|
||||
name: CI
|
||||
|
||||
on: [pull_request]
|
||||
|
||||
jobs:
|
||||
integration-test:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
with:
|
||||
fetch-depth: 2
|
||||
|
||||
- name: Get changed files
|
||||
id: changed-files
|
||||
uses: tj-actions/changed-files@v14.1
|
||||
with:
|
||||
files: |
|
||||
go.*
|
||||
**/*.go
|
||||
integration_test/
|
||||
config-example.yaml
|
||||
|
||||
- name: Setup Go
|
||||
if: steps.changed-files.outputs.any_changed == 'true'
|
||||
uses: actions/setup-go@v2
|
||||
with:
|
||||
go-version: "1.18.0"
|
||||
|
||||
- name: Run Integration tests
|
||||
if: steps.changed-files.outputs.any_changed == 'true'
|
||||
run: make test_integration
|
||||
62
.github/workflows/test.yml
vendored
62
.github/workflows/test.yml
vendored
@@ -3,43 +3,41 @@ name: CI
|
||||
on: [push, pull_request]
|
||||
|
||||
jobs:
|
||||
# The "build" workflow
|
||||
test:
|
||||
# The type of runner that the job will run on
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
# Steps represent a sequence of tasks that will be executed as part of the job
|
||||
steps:
|
||||
# Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/checkout@v2
|
||||
with:
|
||||
fetch-depth: 2
|
||||
|
||||
# Install and run golangci-lint as a separate step, it's much faster this
|
||||
# way because this action has caching. It'll get run again in `make lint`
|
||||
# below, but it's still much faster in the end than installing
|
||||
# golangci-lint manually in the `Run lint` step.
|
||||
- uses: golangci/golangci-lint-action@v2
|
||||
with:
|
||||
args: --timeout 2m
|
||||
|
||||
# Setup Go
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v2
|
||||
with:
|
||||
go-version: '1.16.3' # The Go version to download (if necessary) and use.
|
||||
- name: Get changed files
|
||||
id: changed-files
|
||||
uses: tj-actions/changed-files@v14.1
|
||||
with:
|
||||
files: |
|
||||
go.*
|
||||
**/*.go
|
||||
integration_test/
|
||||
config-example.yaml
|
||||
|
||||
# Install all the dependencies
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
go version
|
||||
go install golang.org/x/lint/golint@latest
|
||||
sudo apt update
|
||||
sudo apt install -y make
|
||||
|
||||
- name: Run tests
|
||||
run: make test
|
||||
- name: Setup Go
|
||||
if: steps.changed-files.outputs.any_changed == 'true'
|
||||
uses: actions/setup-go@v2
|
||||
with:
|
||||
go-version: "1.18.0"
|
||||
|
||||
- name: Run lint
|
||||
run: make lint
|
||||
- name: Install dependencies
|
||||
if: steps.changed-files.outputs.any_changed == 'true'
|
||||
run: |
|
||||
go version
|
||||
sudo apt update
|
||||
sudo apt install -y make
|
||||
|
||||
- name: Run build
|
||||
run: make
|
||||
- name: Run tests
|
||||
if: steps.changed-files.outputs.any_changed == 'true'
|
||||
run: make test
|
||||
|
||||
- name: Run build
|
||||
if: steps.changed-files.outputs.any_changed == 'true'
|
||||
run: make
|
||||
|
||||
9
.gitignore
vendored
9
.gitignore
vendored
@@ -16,5 +16,14 @@
|
||||
|
||||
/headscale
|
||||
config.json
|
||||
config.yaml
|
||||
derp.yaml
|
||||
*.hujson
|
||||
*.key
|
||||
/db.sqlite
|
||||
*.sqlite3
|
||||
|
||||
# Exclude Jetbrains Editors
|
||||
.idea
|
||||
|
||||
test_output/
|
||||
|
||||
58
.golangci.yaml
Normal file
58
.golangci.yaml
Normal file
@@ -0,0 +1,58 @@
|
||||
---
|
||||
run:
|
||||
timeout: 10m
|
||||
|
||||
issues:
|
||||
skip-dirs:
|
||||
- gen
|
||||
linters:
|
||||
enable-all: true
|
||||
disable:
|
||||
- exhaustivestruct
|
||||
- revive
|
||||
- lll
|
||||
- interfacer
|
||||
- scopelint
|
||||
- maligned
|
||||
- golint
|
||||
- gofmt
|
||||
- gochecknoglobals
|
||||
- gochecknoinits
|
||||
- gocognit
|
||||
- funlen
|
||||
- exhaustivestruct
|
||||
- tagliatelle
|
||||
- godox
|
||||
- ireturn
|
||||
|
||||
# We should strive to enable these:
|
||||
- wrapcheck
|
||||
- dupl
|
||||
- makezero
|
||||
- maintidx
|
||||
|
||||
# We might want to enable this, but it might be a lot of work
|
||||
- cyclop
|
||||
- nestif
|
||||
- wsl # might be incompatible with gofumpt
|
||||
- testpackage
|
||||
- paralleltest
|
||||
|
||||
linters-settings:
|
||||
varnamelen:
|
||||
ignore-type-assert-ok: true
|
||||
ignore-map-index-ok: true
|
||||
ignore-names:
|
||||
- err
|
||||
- db
|
||||
- id
|
||||
- ip
|
||||
- ok
|
||||
- c
|
||||
- tt
|
||||
|
||||
gocritic:
|
||||
disabled-checks:
|
||||
- appendAssign
|
||||
# TODO(kradalby): Remove this
|
||||
- ifElseChain
|
||||
@@ -1,59 +1,65 @@
|
||||
# This is an example .goreleaser.yml file with some sane defaults.
|
||||
# Make sure to check the documentation at http://goreleaser.com
|
||||
---
|
||||
before:
|
||||
hooks:
|
||||
- go mod tidy
|
||||
- go mod tidy -compat=1.17
|
||||
|
||||
release:
|
||||
prerelease: auto
|
||||
|
||||
builds:
|
||||
- id: darwin-amd64
|
||||
main: ./cmd/headscale/headscale.go
|
||||
mod_timestamp: '{{ .CommitTimestamp }}'
|
||||
mod_timestamp: "{{ .CommitTimestamp }}"
|
||||
env:
|
||||
- CGO_ENABLED=0
|
||||
goos:
|
||||
- darwin
|
||||
goarch:
|
||||
- amd64
|
||||
env:
|
||||
- PKG_CONFIG_SYSROOT_DIR=/sysroot/macos/amd64
|
||||
- PKG_CONFIG_PATH=/sysroot/macos/amd64/usr/local/lib/pkgconfig
|
||||
- CC=o64-clang
|
||||
- CXX=o64-clang++
|
||||
flags:
|
||||
- -mod=readonly
|
||||
ldflags:
|
||||
- -s -w -X main.version={{.Version}}
|
||||
- -s -w -X github.com/juanfont/headscale/cmd/headscale/cli.Version=v{{.Version}}
|
||||
|
||||
- id: linux-armhf
|
||||
main: ./cmd/headscale/headscale.go
|
||||
mod_timestamp: '{{ .CommitTimestamp }}'
|
||||
mod_timestamp: "{{ .CommitTimestamp }}"
|
||||
env:
|
||||
- CGO_ENABLED=0
|
||||
goos:
|
||||
- linux
|
||||
goarch:
|
||||
- arm
|
||||
goarm:
|
||||
- 7
|
||||
env:
|
||||
- CC=arm-linux-gnueabihf-gcc
|
||||
- CXX=arm-linux-gnueabihf-g++
|
||||
- CGO_FLAGS=--sysroot=/sysroot/linux/armhf
|
||||
- CGO_LDFLAGS=--sysroot=/sysroot/linux/armhf
|
||||
- PKG_CONFIG_SYSROOT_DIR=/sysroot/linux/armhf
|
||||
- PKG_CONFIG_PATH=/sysroot/linux/armhf/opt/vc/lib/pkgconfig:/sysroot/linux/armhf/usr/lib/arm-linux-gnueabihf/pkgconfig:/sysroot/linux/armhf/usr/lib/pkgconfig:/sysroot/linux/armhf/usr/local/lib/pkgconfig
|
||||
- "7"
|
||||
flags:
|
||||
- -mod=readonly
|
||||
ldflags:
|
||||
- -s -w -X main.version={{.Version}}
|
||||
|
||||
- -s -w -X github.com/juanfont/headscale/cmd/headscale/cli.Version=v{{.Version}}
|
||||
|
||||
- id: linux-amd64
|
||||
mod_timestamp: "{{ .CommitTimestamp }}"
|
||||
env:
|
||||
- CGO_ENABLED=1
|
||||
- CGO_ENABLED=0
|
||||
goos:
|
||||
- linux
|
||||
goarch:
|
||||
- amd64
|
||||
goarm:
|
||||
- 6
|
||||
- 7
|
||||
main: ./cmd/headscale/headscale.go
|
||||
mod_timestamp: '{{ .CommitTimestamp }}'
|
||||
ldflags:
|
||||
- -s -w -X github.com/juanfont/headscale/cmd/headscale/cli.Version=v{{.Version}}
|
||||
|
||||
- id: linux-arm64
|
||||
mod_timestamp: "{{ .CommitTimestamp }}"
|
||||
env:
|
||||
- CGO_ENABLED=0
|
||||
goos:
|
||||
- linux
|
||||
goarch:
|
||||
- arm64
|
||||
main: ./cmd/headscale/headscale.go
|
||||
ldflags:
|
||||
- -s -w -X github.com/juanfont/headscale/cmd/headscale/cli.Version=v{{.Version}}
|
||||
|
||||
archives:
|
||||
- id: golang-cross
|
||||
@@ -61,17 +67,17 @@ archives:
|
||||
- darwin-amd64
|
||||
- linux-armhf
|
||||
- linux-amd64
|
||||
- linux-arm64
|
||||
name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}"
|
||||
format: zip
|
||||
# wrap_in_directory: true
|
||||
format: binary
|
||||
|
||||
checksum:
|
||||
name_template: 'checksums.txt'
|
||||
name_template: "checksums.txt"
|
||||
snapshot:
|
||||
name_template: "{{ .Tag }}-next"
|
||||
changelog:
|
||||
sort: asc
|
||||
filters:
|
||||
exclude:
|
||||
- '^docs:'
|
||||
- '^test:'
|
||||
- "^docs:"
|
||||
- "^test:"
|
||||
|
||||
132
CHANGELOG.md
Normal file
132
CHANGELOG.md
Normal file
@@ -0,0 +1,132 @@
|
||||
# CHANGELOG
|
||||
|
||||
## 0.15.0 (2022-xx-xx)
|
||||
|
||||
**Note:** Take a backup of your database before upgrading.
|
||||
|
||||
### BREAKING
|
||||
|
||||
- Boundaries between Namespaces has been removed and all nodes can communicate by default [#357](https://github.com/juanfont/headscale/pull/357)
|
||||
- To limit access between nodes, use [ACLs](./docs/acls.md).
|
||||
- `/metrics` is now a configurable host:port endpoint: [#344](https://github.com/juanfont/headscale/pull/344). You must update your `config.yaml` file to include:
|
||||
```yaml
|
||||
metrics_listen_addr: 127.0.0.1:9090
|
||||
```
|
||||
|
||||
### Features
|
||||
|
||||
- Add support for writing ACL files with YAML [#359](https://github.com/juanfont/headscale/pull/359)
|
||||
- Users can now use emails in ACL's groups [#372](https://github.com/juanfont/headscale/issues/372)
|
||||
- Add shorthand aliases for commands and subcommands [#376](https://github.com/juanfont/headscale/pull/376)
|
||||
- Add `/windows` endpoint for Windows configuration instructions + registry file download [#392](https://github.com/juanfont/headscale/pull/392)
|
||||
- Added embedded DERP server into Headscale [#388](https://github.com/juanfont/headscale/pull/388)
|
||||
|
||||
### Changes
|
||||
|
||||
- Fix a bug were the same IP could be assigned to multiple hosts if joined in quick succession [#346](https://github.com/juanfont/headscale/pull/346)
|
||||
- Simplify the code behind registration of machines [#366](https://github.com/juanfont/headscale/pull/366)
|
||||
- Nodes are now only written to database if they are registrated successfully
|
||||
- Fix a limitation in the ACLs that prevented users to write rules with `*` as source [#374](https://github.com/juanfont/headscale/issues/374)
|
||||
- Reduce the overhead of marshal/unmarshal for Hostinfo, routes and endpoints by using specific types in Machine [#371](https://github.com/juanfont/headscale/pull/371)
|
||||
- Apply normalization function to FQDN on hostnames when hosts registers and retrieve informations [#363](https://github.com/juanfont/headscale/issues/363)
|
||||
|
||||
## 0.14.0 (2022-02-24)
|
||||
|
||||
**UPCOMING ### BREAKING
|
||||
From the **next\*\* version (`0.15.0`), all machines will be able to communicate regardless of
|
||||
if they are in the same namespace. This means that the behaviour currently limited to ACLs
|
||||
will become default. From version `0.15.0`, all limitation of communications must be done
|
||||
with ACLs.
|
||||
|
||||
This is a part of aligning `headscale`'s behaviour with Tailscale's upstream behaviour.
|
||||
|
||||
### BREAKING
|
||||
|
||||
- ACLs have been rewritten to align with the bevaviour Tailscale Control Panel provides. **NOTE:** This is only active if you use ACLs
|
||||
- Namespaces are now treated as Users
|
||||
- All machines can communicate with all machines by default
|
||||
- Tags should now work correctly and adding a host to Headscale should now reload the rules.
|
||||
- The documentation have a [fictional example](docs/acls.md) that should cover some use cases of the ACLs features
|
||||
|
||||
### Features
|
||||
|
||||
- Add support for configurable mTLS [docs](docs/tls.md#configuring-mutual-tls-authentication-mtls) [#297](https://github.com/juanfont/headscale/pull/297)
|
||||
|
||||
### Changes
|
||||
|
||||
- Remove dependency on CGO (switch from CGO SQLite to pure Go) [#346](https://github.com/juanfont/headscale/pull/346)
|
||||
|
||||
**0.13.0 (2022-02-18):**
|
||||
|
||||
### Features
|
||||
|
||||
- Add IPv6 support to the prefix assigned to namespaces
|
||||
- Add API Key support
|
||||
- Enable remote control of `headscale` via CLI [docs](docs/remote-cli.md)
|
||||
- Enable HTTP API (beta, subject to change)
|
||||
- OpenID Connect users will be mapped per namespaces
|
||||
- Each user will get its own namespace, created if it does not exist
|
||||
- `oidc.domain_map` option has been removed
|
||||
- `strip_email_domain` option has been added (see [config-example.yaml](./config_example.yaml))
|
||||
|
||||
### Changes
|
||||
|
||||
- `ip_prefix` is now superseded by `ip_prefixes` in the configuration [#208](https://github.com/juanfont/headscale/pull/208)
|
||||
- Upgrade `tailscale` (1.20.4) and other dependencies to latest [#314](https://github.com/juanfont/headscale/pull/314)
|
||||
- fix swapped machine<->namespace labels in `/metrics` [#312](https://github.com/juanfont/headscale/pull/312)
|
||||
- remove key-value based update mechanism for namespace changes [#316](https://github.com/juanfont/headscale/pull/316)
|
||||
|
||||
**0.12.4 (2022-01-29):**
|
||||
|
||||
### Changes
|
||||
|
||||
- Make gRPC Unix Socket permissions configurable [#292](https://github.com/juanfont/headscale/pull/292)
|
||||
- Trim whitespace before reading Private Key from file [#289](https://github.com/juanfont/headscale/pull/289)
|
||||
- Add new command to generate a private key for `headscale` [#290](https://github.com/juanfont/headscale/pull/290)
|
||||
- Fixed issue where hosts deleted from control server may be written back to the database, as long as they are connected to the control server [#278](https://github.com/juanfont/headscale/pull/278)
|
||||
|
||||
## 0.12.3 (2022-01-13)
|
||||
|
||||
### Changes
|
||||
|
||||
- Added Alpine container [#270](https://github.com/juanfont/headscale/pull/270)
|
||||
- Minor updates in dependencies [#271](https://github.com/juanfont/headscale/pull/271)
|
||||
|
||||
## 0.12.2 (2022-01-11)
|
||||
|
||||
Happy New Year!
|
||||
|
||||
### Changes
|
||||
|
||||
- Fix Docker release [#258](https://github.com/juanfont/headscale/pull/258)
|
||||
- Rewrite main docs [#262](https://github.com/juanfont/headscale/pull/262)
|
||||
- Improve Docker docs [#263](https://github.com/juanfont/headscale/pull/263)
|
||||
|
||||
## 0.12.1 (2021-12-24)
|
||||
|
||||
(We are skipping 0.12.0 to correct a mishap done weeks ago with the version tagging)
|
||||
|
||||
### BREAKING
|
||||
|
||||
- Upgrade to Tailscale 1.18 [#229](https://github.com/juanfont/headscale/pull/229)
|
||||
- This change requires a new format for private key, private keys are now generated automatically:
|
||||
1. Delete your current key
|
||||
2. Restart `headscale`, a new key will be generated.
|
||||
3. Restart all Tailscale clients to fetch the new key
|
||||
|
||||
### Changes
|
||||
|
||||
- Unify configuration example [#197](https://github.com/juanfont/headscale/pull/197)
|
||||
- Add stricter linting and formatting [#223](https://github.com/juanfont/headscale/pull/223)
|
||||
|
||||
### Features
|
||||
|
||||
- Add gRPC and HTTP API (HTTP API is currently disabled) [#204](https://github.com/juanfont/headscale/pull/204)
|
||||
- Use gRPC between the CLI and the server [#206](https://github.com/juanfont/headscale/pull/206), [#212](https://github.com/juanfont/headscale/pull/212)
|
||||
- Beta OpenID Connect support [#126](https://github.com/juanfont/headscale/pull/126), [#227](https://github.com/juanfont/headscale/pull/227)
|
||||
|
||||
## 0.11.0 (2021-10-25)
|
||||
|
||||
### BREAKING
|
||||
|
||||
- Make headscale fetch DERP map from URL and file [#196](https://github.com/juanfont/headscale/pull/196)
|
||||
22
Dockerfile
22
Dockerfile
@@ -1,12 +1,22 @@
|
||||
FROM golang:latest AS build
|
||||
# Builder image
|
||||
FROM docker.io/golang:1.18.0-bullseye AS build
|
||||
ENV GOPATH /go
|
||||
COPY . /go/src/headscale
|
||||
WORKDIR /go/src/headscale
|
||||
RUN go install -a -ldflags="-extldflags=-static" -tags netgo,sqlite_omit_load_extension ./cmd/headscale
|
||||
|
||||
COPY go.mod go.sum /go/src/headscale/
|
||||
RUN go mod download
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN GGO_ENABLED=0 GOOS=linux go install -a ./cmd/headscale
|
||||
RUN strip /go/bin/headscale
|
||||
RUN test -e /go/bin/headscale
|
||||
|
||||
FROM scratch
|
||||
COPY --from=build /go/bin/headscale /go/bin/headscale
|
||||
# Production image
|
||||
FROM gcr.io/distroless/base-debian11
|
||||
|
||||
COPY --from=build /go/bin/headscale /bin/headscale
|
||||
ENV TZ UTC
|
||||
|
||||
EXPOSE 8080/tcp
|
||||
ENTRYPOINT ["/go/bin/headscale"]
|
||||
CMD ["headscale"]
|
||||
|
||||
23
Dockerfile.alpine
Normal file
23
Dockerfile.alpine
Normal file
@@ -0,0 +1,23 @@
|
||||
# Builder image
|
||||
FROM docker.io/golang:1.18.0-alpine AS build
|
||||
ENV GOPATH /go
|
||||
WORKDIR /go/src/headscale
|
||||
|
||||
COPY go.mod go.sum /go/src/headscale/
|
||||
RUN apk add gcc musl-dev
|
||||
RUN go mod download
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN GGO_ENABLED=0 GOOS=linux go install -a ./cmd/headscale
|
||||
RUN strip /go/bin/headscale
|
||||
RUN test -e /go/bin/headscale
|
||||
|
||||
# Production image
|
||||
FROM docker.io/alpine:latest
|
||||
|
||||
COPY --from=build /go/bin/headscale /bin/headscale
|
||||
ENV TZ UTC
|
||||
|
||||
EXPOSE 8080/tcp
|
||||
CMD ["headscale"]
|
||||
23
Dockerfile.debug
Normal file
23
Dockerfile.debug
Normal file
@@ -0,0 +1,23 @@
|
||||
# Builder image
|
||||
FROM docker.io/golang:1.18.0-bullseye AS build
|
||||
ENV GOPATH /go
|
||||
WORKDIR /go/src/headscale
|
||||
|
||||
COPY go.mod go.sum /go/src/headscale/
|
||||
RUN go mod download
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN GGO_ENABLED=0 GOOS=linux go install -a ./cmd/headscale
|
||||
RUN test -e /go/bin/headscale
|
||||
|
||||
# Debug image
|
||||
FROM gcr.io/distroless/base-debian11:debug
|
||||
|
||||
COPY --from=build /go/bin/headscale /bin/headscale
|
||||
ENV TZ UTC
|
||||
|
||||
# Need to reset the entrypoint or everything will run as a busybox script
|
||||
ENTRYPOINT []
|
||||
EXPOSE 8080/tcp
|
||||
CMD ["headscale"]
|
||||
16
Dockerfile.tailscale
Normal file
16
Dockerfile.tailscale
Normal file
@@ -0,0 +1,16 @@
|
||||
FROM ubuntu:latest
|
||||
|
||||
ARG TAILSCALE_VERSION
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y gnupg curl \
|
||||
&& curl -fsSL https://pkgs.tailscale.com/stable/ubuntu/focal.gpg | apt-key add - \
|
||||
&& curl -fsSL https://pkgs.tailscale.com/stable/ubuntu/focal.list | tee /etc/apt/sources.list.d/tailscale.list \
|
||||
&& apt-get update \
|
||||
&& apt-get install -y ca-certificates tailscale=${TAILSCALE_VERSION} dnsutils \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
ADD integration_test/etc_embedded_derp/tls/server.crt /usr/local/share/ca-certificates/
|
||||
RUN chmod 644 /usr/local/share/ca-certificates/server.crt
|
||||
|
||||
RUN update-ca-certificates
|
||||
40
Makefile
40
Makefile
@@ -1,14 +1,31 @@
|
||||
# Calculate version
|
||||
version = $(shell ./scripts/version-at-commit.sh)
|
||||
|
||||
rwildcard=$(foreach d,$(wildcard $1*),$(call rwildcard,$d/,$2) $(filter $(subst *,%,$2),$d))
|
||||
|
||||
# GO_SOURCES = $(wildcard *.go)
|
||||
# PROTO_SOURCES = $(wildcard **/*.proto)
|
||||
GO_SOURCES = $(call rwildcard,,*.go)
|
||||
PROTO_SOURCES = $(call rwildcard,,*.proto)
|
||||
|
||||
|
||||
build:
|
||||
go build -ldflags "-s -w -X main.version=$(version)" cmd/headscale/headscale.go
|
||||
GGO_ENABLED=0 go build -ldflags "-s -w -X github.com/juanfont/headscale/cmd/headscale/cli.Version=$(version)" cmd/headscale/headscale.go
|
||||
|
||||
dev: lint test build
|
||||
|
||||
test:
|
||||
@go test -coverprofile=coverage.out ./...
|
||||
|
||||
test_integration:
|
||||
go test -failfast -tags integration -timeout 30m -count=1 ./...
|
||||
|
||||
test_integration_cli:
|
||||
go test -tags integration -v integration_cli_test.go integration_common_test.go
|
||||
|
||||
test_integration_derp:
|
||||
go test -tags integration -v integration_embedded_derp_test.go integration_common_test.go
|
||||
|
||||
coverprofile_func:
|
||||
go tool cover -func=coverage.out
|
||||
|
||||
@@ -16,9 +33,26 @@ coverprofile_html:
|
||||
go tool cover -html=coverage.out
|
||||
|
||||
lint:
|
||||
golint
|
||||
golangci-lint run
|
||||
golangci-lint run --fix --timeout 10m
|
||||
|
||||
fmt:
|
||||
prettier --write '**/**.{ts,js,md,yaml,yml,sass,css,scss,html}'
|
||||
golines --max-len=88 --base-formatter=gofumpt -w $(GO_SOURCES)
|
||||
clang-format -style="{BasedOnStyle: Google, IndentWidth: 4, AlignConsecutiveDeclarations: true, AlignConsecutiveAssignments: true, ColumnLimit: 0}" -i $(PROTO_SOURCES)
|
||||
|
||||
proto-lint:
|
||||
cd proto/ && buf lint
|
||||
|
||||
compress: build
|
||||
upx --brute headscale
|
||||
|
||||
generate:
|
||||
rm -rf gen
|
||||
buf generate proto
|
||||
|
||||
install-protobuf-plugins:
|
||||
go install \
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2/protoc-gen-grpc-gateway \
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2/protoc-gen-openapiv2 \
|
||||
google.golang.org/protobuf/cmd/protoc-gen-go \
|
||||
google.golang.org/grpc/cmd/protoc-gen-go-grpc
|
||||
|
||||
654
README.md
654
README.md
@@ -1,189 +1,525 @@
|
||||
# Headscale
|
||||
# headscale
|
||||
|
||||
[](https://gitter.im/headscale-dev/community?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge) 
|
||||

|
||||
|
||||
An open source, self-hosted implementation of the Tailscale coordination server.
|
||||
An open source, self-hosted implementation of the Tailscale control server.
|
||||
|
||||
## Overview
|
||||
Join our [Discord](https://discord.gg/XcQxk2VHjx) server for a chat.
|
||||
|
||||
Tailscale is [a modern VPN](https://tailscale.com/) built on top of [Wireguard](https://www.wireguard.com/). It [works like an overlay network](https://tailscale.com/blog/how-tailscale-works/) between the computers of your networks - using all kinds of [NAT traversal sorcery](https://tailscale.com/blog/how-nat-traversal-works/).
|
||||
**Note:** Always select the same GitHub tag as the released version you use
|
||||
to ensure you have the correct example configuration and documentation.
|
||||
The `main` branch might contain unreleased changes.
|
||||
|
||||
Everything in Tailscale is Open Source, except the GUI clients for proprietary OS (Windows and macOS/iOS), and the 'coordination/control server'.
|
||||
## What is Tailscale
|
||||
|
||||
The control server works as an exchange point of Wireguard public keys for the nodes in the Tailscale network. It also assigns the IP addresses of the clients, creates the boundaries between each user, enables sharing machines between users, and exposes the advertised routes of your nodes.
|
||||
Tailscale is [a modern VPN](https://tailscale.com/) built on top of
|
||||
[Wireguard](https://www.wireguard.com/).
|
||||
It [works like an overlay network](https://tailscale.com/blog/how-tailscale-works/)
|
||||
between the computers of your networks - using
|
||||
[NAT traversal](https://tailscale.com/blog/how-nat-traversal-works/).
|
||||
|
||||
Headscale implements this coordination server.
|
||||
Everything in Tailscale is Open Source, except the GUI clients for proprietary OS
|
||||
(Windows and macOS/iOS), and the control server.
|
||||
|
||||
## Status
|
||||
The control server works as an exchange point of Wireguard public keys for the
|
||||
nodes in the Tailscale network. It assigns the IP addresses of the clients,
|
||||
creates the boundaries between each user, enables sharing machines between users,
|
||||
and exposes the advertised routes of your nodes.
|
||||
|
||||
- [x] Base functionality (nodes can communicate with each other)
|
||||
- [x] Node registration through the web flow
|
||||
- [x] Network changes are relied to the nodes
|
||||
- [x] Namespace support (~equivalent to multi-user in Tailscale.com)
|
||||
- [x] Routing (advertise & accept, including exit nodes)
|
||||
- [x] Node registration via pre-auth keys (including reusable keys, and ephemeral node support)
|
||||
- [X] JSON-formatted output
|
||||
- [X] ACLs
|
||||
- [ ] Share nodes between ~~users~~ namespaces
|
||||
- [ ] DNS
|
||||
A [Tailscale network (tailnet)](https://tailscale.com/kb/1136/tailnet/) is private
|
||||
network which Tailscale assigns to a user in terms of private users or an
|
||||
organisations.
|
||||
|
||||
## Design goal
|
||||
|
||||
## Roadmap 🤷
|
||||
`headscale` aims to implement a self-hosted, open source alternative to the Tailscale
|
||||
control server. `headscale` has a narrower scope and an instance of `headscale`
|
||||
implements a _single_ Tailnet, which is typically what a single organisation, or
|
||||
home/personal setup would use.
|
||||
|
||||
We are now focusing on adding integration tests with the official clients.
|
||||
`headscale` uses terms that maps to Tailscale's control server, consult the
|
||||
[glossary](./docs/glossary.md) for explainations.
|
||||
|
||||
Suggestions/PRs welcomed!
|
||||
## Support
|
||||
|
||||
If you like `headscale` and find it useful, there is a sponsorship and donation
|
||||
buttons available in the repo.
|
||||
|
||||
If you would like to sponsor features, bugs or prioritisation, reach out to
|
||||
one of the maintainers.
|
||||
|
||||
## Running it
|
||||
## Features
|
||||
|
||||
1. Download the Headscale binary https://github.com/juanfont/headscale/releases, and place it somewhere in your PATH
|
||||
|
||||
- Full "base" support of Tailscale's features
|
||||
- Configurable DNS
|
||||
- [Split DNS](https://tailscale.com/kb/1054/dns/#using-dns-settings-in-the-admin-console)
|
||||
- Node registration
|
||||
- Single-Sign-On (via Open ID Connect)
|
||||
- Pre authenticated key
|
||||
- Taildrop (File Sharing)
|
||||
- [Access control lists](https://tailscale.com/kb/1018/acls/)
|
||||
- [MagicDNS](https://tailscale.com/kb/1081/magicdns)
|
||||
- Support for multiple IP ranges in the tailnet
|
||||
- Dual stack (IPv4 and IPv6)
|
||||
- Routing advertising (including exit nodes)
|
||||
- Ephemeral nodes
|
||||
- Embedded [DERP server](https://tailscale.com/blog/how-tailscale-works/#encrypted-tcp-relays-derp)
|
||||
|
||||
2. (Optional, you can also use SQLite) Get yourself a PostgreSQL DB running
|
||||
## Client OS support
|
||||
|
||||
```shell
|
||||
docker run --name headscale -e POSTGRES_DB=headscale -e \
|
||||
POSTGRES_USER=foo -e POSTGRES_PASSWORD=bar -p 5432:5432 -d postgres
|
||||
```
|
||||
| OS | Supports headscale |
|
||||
| ------- | ----------------------------------------------------------------------------------------------------------------- |
|
||||
| Linux | Yes |
|
||||
| OpenBSD | Yes |
|
||||
| FreeBSD | Yes |
|
||||
| macOS | Yes (see `/apple` on your headscale for more information) |
|
||||
| Windows | Yes [docs](./docs/windows-client.md) |
|
||||
| Android | [You need to compile the client yourself](https://github.com/juanfont/headscale/issues/58#issuecomment-885255270) |
|
||||
| iOS | Not yet |
|
||||
|
||||
3. Set some stuff up (headscale Wireguard keys & the config.json file)
|
||||
```shell
|
||||
wg genkey > private.key
|
||||
wg pubkey < private.key > public.key # not needed
|
||||
|
||||
# Postgres
|
||||
cp config.json.postgres.example config.json
|
||||
# or
|
||||
# SQLite
|
||||
cp config.json.sqlite.example config.json
|
||||
```
|
||||
|
||||
4. Create a namespace (a namespace is a 'tailnet', a group of Tailscale nodes that can talk to each other)
|
||||
```shell
|
||||
headscale namespace create myfirstnamespace
|
||||
```
|
||||
|
||||
5. Run the server
|
||||
```shell
|
||||
headscale serve
|
||||
```
|
||||
|
||||
6. If you used tailscale.com before in your nodes, make sure you clear the tailscaled data folder
|
||||
```shell
|
||||
systemctl stop tailscaled
|
||||
rm -fr /var/lib/tailscale
|
||||
systemctl start tailscaled
|
||||
```
|
||||
|
||||
7. Add your first machine
|
||||
```shell
|
||||
tailscale up -login-server YOUR_HEADSCALE_URL
|
||||
```
|
||||
|
||||
8. Navigate to the URL you will get with `tailscale up`, where you'll find your machine key.
|
||||
|
||||
9. In the server, register your machine to a namespace with the CLI
|
||||
```shell
|
||||
headscale -n myfirstnamespace node register YOURMACHINEKEY
|
||||
```
|
||||
|
||||
Alternatively, you can use Auth Keys to register your machines:
|
||||
|
||||
1. Create an authkey
|
||||
```shell
|
||||
headscale -n myfirstnamespace preauthkey create --reusable --expiration 24h
|
||||
```
|
||||
|
||||
2. Use the authkey from your machine to register it
|
||||
```shell
|
||||
tailscale up -login-server YOUR_HEADSCALE_URL --authkey YOURAUTHKEY
|
||||
```
|
||||
|
||||
If you create an authkey with the `--ephemeral` flag, that key will create ephemeral nodes. This implies that `--reusable` is true.
|
||||
|
||||
Please bear in mind that all the commands from headscale support adding `-o json` or `-o json-line` to get a nicely JSON-formatted output.
|
||||
|
||||
|
||||
## Configuration reference
|
||||
|
||||
Headscale's configuration file is named `config.json` or `config.yaml`. Headscale will look for it in `/etc/headscale`, `~/.headscale` and finally the directory from where the Headscale binary is executed.
|
||||
|
||||
```
|
||||
"server_url": "http://192.168.1.12:8000",
|
||||
"listen_addr": "0.0.0.0:8000",
|
||||
```
|
||||
|
||||
`server_url` is the external URL via which Headscale is reachable. `listen_addr` is the IP address and port the Headscale program should listen on.
|
||||
|
||||
```
|
||||
"private_key_path": "private.key",
|
||||
```
|
||||
|
||||
`private_key_path` is the path to the Wireguard private key. If the path is relative, it will be interpreted as relative to the directory the configuration file was read from.
|
||||
|
||||
```
|
||||
"derp_map_path": "derp.yaml",
|
||||
```
|
||||
|
||||
`derp_map_path` is the path to the [DERP](https://pkg.go.dev/tailscale.com/derp) map file. If the path is relative, it will be interpreted as relative to the directory the configuration file was read from.
|
||||
|
||||
```
|
||||
"ephemeral_node_inactivity_timeout": "30m",
|
||||
```
|
||||
|
||||
`ephemeral_node_inactivity_timeout` is the timeout after which inactive ephemeral node records will be deleted from the database. The default is 30 minutes. This value must be higher than 65 seconds (the keepalive timeout for the HTTP long poll is 60 seconds, plus a few seconds to avoid race conditions).
|
||||
|
||||
```
|
||||
"db_host": "localhost",
|
||||
"db_port": 5432,
|
||||
"db_name": "headscale",
|
||||
"db_user": "foo",
|
||||
"db_pass": "bar",
|
||||
```
|
||||
|
||||
The fields starting with `db_` are used for the PostgreSQL connection information.
|
||||
|
||||
|
||||
### Running the service via TLS (optional)
|
||||
|
||||
```
|
||||
"tls_cert_path": ""
|
||||
"tls_key_path": ""
|
||||
```
|
||||
|
||||
Headscale can be configured to expose its web service via TLS. To configure the certificate and key file manually, set the `tls_cert_path` and `tls_cert_path` configuration parameters. If the path is relative, it will be interpreted as relative to the directory the configuration file was read from.
|
||||
|
||||
```
|
||||
"tls_letsencrypt_hostname": "",
|
||||
"tls_letsencrypt_cache_dir": ".cache",
|
||||
"tls_letsencrypt_challenge_type": "HTTP-01",
|
||||
```
|
||||
|
||||
To get a certificate automatically via [Let's Encrypt](https://letsencrypt.org/), set `tls_letsencrypt_hostname` to the desired certificate hostname. This name must resolve to the IP address(es) Headscale is reachable on (i.e., it must correspond to the `server_url` configuration parameter). The certificate and Let's Encrypt account credentials will be stored in the directory configured in `tls_letsencrypt_cache_dir`. If the path is relative, it will be interpreted as relative to the directory the configuration file was read from. The certificate will automatically be renewed as needed. The default challenge type HTTP-01 requires that Headscale listens on port 80 for the Let's Encrypt automated validation, in addition to whatever port is configured in `listen_addr`. Alternatively, `tls_letsencrypt_challenge_type` can be set to `TLS-ALPN-01`. In this configuration, Headscale must be reachable via port 443, but port 80 is not required.
|
||||
|
||||
|
||||
### Policy ACLs
|
||||
|
||||
Headscale implements the same policy ACLs as Tailscale.com, adapted to the self-hosted environment.
|
||||
|
||||
For instance, instead of referring to users when defining groups you must
|
||||
use namespaces (which are the equivalent to user/logins in Tailscale.com).
|
||||
|
||||
Please check https://tailscale.com/kb/1018/acls/, and `./tests/acls/` in this repo for working examples.
|
||||
## Running headscale
|
||||
|
||||
Please have a look at the documentation under [`docs/`](docs/).
|
||||
|
||||
## Disclaimer
|
||||
|
||||
1. We have nothing to do with Tailscale, or Tailscale Inc.
|
||||
2. The purpose of writing this was to learn how Tailscale works.
|
||||
2. The purpose of Headscale is maintaining a working, self-hosted Tailscale control panel.
|
||||
|
||||
## Contributing
|
||||
|
||||
To contribute to headscale you would need the lastest version of [Go](https://golang.org)
|
||||
and [Buf](https://buf.build)(Protobuf generator).
|
||||
|
||||
## More on Tailscale
|
||||
PRs and suggestions are welcome.
|
||||
|
||||
- https://tailscale.com/blog/how-tailscale-works/
|
||||
- https://tailscale.com/blog/tailscale-key-management/
|
||||
- https://tailscale.com/blog/an-unlikely-database-migration/
|
||||
### Code style
|
||||
|
||||
To ensure we have some consistency with a growing number of contributions,
|
||||
this project has adopted linting and style/formatting rules:
|
||||
|
||||
The **Go** code is linted with [`golangci-lint`](https://golangci-lint.run) and
|
||||
formatted with [`golines`](https://github.com/segmentio/golines) (width 88) and
|
||||
[`gofumpt`](https://github.com/mvdan/gofumpt).
|
||||
Please configure your editor to run the tools while developing and make sure to
|
||||
run `make lint` and `make fmt` before committing any code.
|
||||
|
||||
The **Proto** code is linted with [`buf`](https://docs.buf.build/lint/overview) and
|
||||
formatted with [`clang-format`](https://clang.llvm.org/docs/ClangFormat.html).
|
||||
|
||||
The **rest** (Markdown, YAML, etc) is formatted with [`prettier`](https://prettier.io).
|
||||
|
||||
Check out the `.golangci.yaml` and `Makefile` to see the specific configuration.
|
||||
|
||||
### Install development tools
|
||||
|
||||
- Go
|
||||
- Buf
|
||||
- Protobuf tools:
|
||||
|
||||
```shell
|
||||
make install-protobuf-plugins
|
||||
```
|
||||
|
||||
### Testing and building
|
||||
|
||||
Some parts of the project require the generation of Go code from Protobuf
|
||||
(if changes are made in `proto/`) and it must be (re-)generated with:
|
||||
|
||||
```shell
|
||||
make generate
|
||||
```
|
||||
|
||||
**Note**: Please check in changes from `gen/` in a separate commit to make it easier to review.
|
||||
|
||||
To run the tests:
|
||||
|
||||
```shell
|
||||
make test
|
||||
```
|
||||
|
||||
To build the program:
|
||||
|
||||
```shell
|
||||
make build
|
||||
```
|
||||
|
||||
## Contributors
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center" style="word-wrap: break-word; width: 150.0; height: 150.0">
|
||||
<a href=https://github.com/kradalby>
|
||||
<img src=https://avatars.githubusercontent.com/u/98431?v=4 width="100;" style="border-radius:50%;align-items:center;justify-content:center;overflow:hidden;padding-top:10px" alt=Kristoffer Dalby/>
|
||||
<br />
|
||||
<sub style="font-size:14px"><b>Kristoffer Dalby</b></sub>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center" style="word-wrap: break-word; width: 150.0; height: 150.0">
|
||||
<a href=https://github.com/juanfont>
|
||||
<img src=https://avatars.githubusercontent.com/u/181059?v=4 width="100;" style="border-radius:50%;align-items:center;justify-content:center;overflow:hidden;padding-top:10px" alt=Juan Font/>
|
||||
<br />
|
||||
<sub style="font-size:14px"><b>Juan Font</b></sub>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center" style="word-wrap: break-word; width: 150.0; height: 150.0">
|
||||
<a href=https://github.com/restanrm>
|
||||
<img src=https://avatars.githubusercontent.com/u/4344371?v=4 width="100;" style="border-radius:50%;align-items:center;justify-content:center;overflow:hidden;padding-top:10px" alt=Adrien Raffin-Caboisse/>
|
||||
<br />
|
||||
<sub style="font-size:14px"><b>Adrien Raffin-Caboisse</b></sub>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center" style="word-wrap: break-word; width: 150.0; height: 150.0">
|
||||
<a href=https://github.com/cure>
|
||||
<img src=https://avatars.githubusercontent.com/u/149135?v=4 width="100;" style="border-radius:50%;align-items:center;justify-content:center;overflow:hidden;padding-top:10px" alt=Ward Vandewege/>
|
||||
<br />
|
||||
<sub style="font-size:14px"><b>Ward Vandewege</b></sub>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center" style="word-wrap: break-word; width: 150.0; height: 150.0">
|
||||
<a href=https://github.com/ohdearaugustin>
|
||||
<img src=https://avatars.githubusercontent.com/u/14001491?v=4 width="100;" style="border-radius:50%;align-items:center;justify-content:center;overflow:hidden;padding-top:10px" alt=ohdearaugustin/>
|
||||
<br />
|
||||
<sub style="font-size:14px"><b>ohdearaugustin</b></sub>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center" style="word-wrap: break-word; width: 150.0; height: 150.0">
|
||||
<a href=https://github.com/e-zk>
|
||||
<img src=https://avatars.githubusercontent.com/u/58356365?v=4 width="100;" style="border-radius:50%;align-items:center;justify-content:center;overflow:hidden;padding-top:10px" alt=e-zk/>
|
||||
<br />
|
||||
<sub style="font-size:14px"><b>e-zk</b></sub>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" style="word-wrap: break-word; width: 150.0; height: 150.0">
|
||||
<a href=https://github.com/arch4ngel>
|
||||
<img src=https://avatars.githubusercontent.com/u/11574161?v=4 width="100;" style="border-radius:50%;align-items:center;justify-content:center;overflow:hidden;padding-top:10px" alt=Justin Angel/>
|
||||
<br />
|
||||
<sub style="font-size:14px"><b>Justin Angel</b></sub>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center" style="word-wrap: break-word; width: 150.0; height: 150.0">
|
||||
<a href=https://github.com/ItalyPaleAle>
|
||||
<img src=https://avatars.githubusercontent.com/u/43508?v=4 width="100;" style="border-radius:50%;align-items:center;justify-content:center;overflow:hidden;padding-top:10px" alt=Alessandro (Ale) Segala/>
|
||||
<br />
|
||||
<sub style="font-size:14px"><b>Alessandro (Ale) Segala</b></sub>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center" style="word-wrap: break-word; width: 150.0; height: 150.0">
|
||||
<a href=https://github.com/unreality>
|
||||
<img src=https://avatars.githubusercontent.com/u/352522?v=4 width="100;" style="border-radius:50%;align-items:center;justify-content:center;overflow:hidden;padding-top:10px" alt=unreality/>
|
||||
<br />
|
||||
<sub style="font-size:14px"><b>unreality</b></sub>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center" style="word-wrap: break-word; width: 150.0; height: 150.0">
|
||||
<a href=https://github.com/reynico>
|
||||
<img src=https://avatars.githubusercontent.com/u/715768?v=4 width="100;" style="border-radius:50%;align-items:center;justify-content:center;overflow:hidden;padding-top:10px" alt=Nico/>
|
||||
<br />
|
||||
<sub style="font-size:14px"><b>Nico</b></sub>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center" style="word-wrap: break-word; width: 150.0; height: 150.0">
|
||||
<a href=https://github.com/negbie>
|
||||
<img src=https://avatars.githubusercontent.com/u/20154956?v=4 width="100;" style="border-radius:50%;align-items:center;justify-content:center;overflow:hidden;padding-top:10px" alt=Eugen Biegler/>
|
||||
<br />
|
||||
<sub style="font-size:14px"><b>Eugen Biegler</b></sub>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center" style="word-wrap: break-word; width: 150.0; height: 150.0">
|
||||
<a href=https://github.com/qbit>
|
||||
<img src=https://avatars.githubusercontent.com/u/68368?v=4 width="100;" style="border-radius:50%;align-items:center;justify-content:center;overflow:hidden;padding-top:10px" alt=Aaron Bieber/>
|
||||
<br />
|
||||
<sub style="font-size:14px"><b>Aaron Bieber</b></sub>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" style="word-wrap: break-word; width: 150.0; height: 150.0">
|
||||
<a href=https://github.com/fdelucchijr>
|
||||
<img src=https://avatars.githubusercontent.com/u/69133647?v=4 width="100;" style="border-radius:50%;align-items:center;justify-content:center;overflow:hidden;padding-top:10px" alt=Fernando De Lucchi/>
|
||||
<br />
|
||||
<sub style="font-size:14px"><b>Fernando De Lucchi</b></sub>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center" style="word-wrap: break-word; width: 150.0; height: 150.0">
|
||||
<a href=https://github.com/hdhoang>
|
||||
<img src=https://avatars.githubusercontent.com/u/12537?v=4 width="100;" style="border-radius:50%;align-items:center;justify-content:center;overflow:hidden;padding-top:10px" alt=Hoàng Đức Hiếu/>
|
||||
<br />
|
||||
<sub style="font-size:14px"><b>Hoàng Đức Hiếu</b></sub>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center" style="word-wrap: break-word; width: 150.0; height: 150.0">
|
||||
<a href=https://github.com/mevansam>
|
||||
<img src=https://avatars.githubusercontent.com/u/403630?v=4 width="100;" style="border-radius:50%;align-items:center;justify-content:center;overflow:hidden;padding-top:10px" alt=Mevan Samaratunga/>
|
||||
<br />
|
||||
<sub style="font-size:14px"><b>Mevan Samaratunga</b></sub>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center" style="word-wrap: break-word; width: 150.0; height: 150.0">
|
||||
<a href=https://github.com/dragetd>
|
||||
<img src=https://avatars.githubusercontent.com/u/3639577?v=4 width="100;" style="border-radius:50%;align-items:center;justify-content:center;overflow:hidden;padding-top:10px" alt=Michael G./>
|
||||
<br />
|
||||
<sub style="font-size:14px"><b>Michael G.</b></sub>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center" style="word-wrap: break-word; width: 150.0; height: 150.0">
|
||||
<a href=https://github.com/ptman>
|
||||
<img src=https://avatars.githubusercontent.com/u/24669?v=4 width="100;" style="border-radius:50%;align-items:center;justify-content:center;overflow:hidden;padding-top:10px" alt=Paul Tötterman/>
|
||||
<br />
|
||||
<sub style="font-size:14px"><b>Paul Tötterman</b></sub>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center" style="word-wrap: break-word; width: 150.0; height: 150.0">
|
||||
<a href=https://github.com/cmars>
|
||||
<img src=https://avatars.githubusercontent.com/u/23741?v=4 width="100;" style="border-radius:50%;align-items:center;justify-content:center;overflow:hidden;padding-top:10px" alt=Casey Marshall/>
|
||||
<br />
|
||||
<sub style="font-size:14px"><b>Casey Marshall</b></sub>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" style="word-wrap: break-word; width: 150.0; height: 150.0">
|
||||
<a href=https://github.com/SilverBut>
|
||||
<img src=https://avatars.githubusercontent.com/u/6560655?v=4 width="100;" style="border-radius:50%;align-items:center;justify-content:center;overflow:hidden;padding-top:10px" alt=Silver Bullet/>
|
||||
<br />
|
||||
<sub style="font-size:14px"><b>Silver Bullet</b></sub>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center" style="word-wrap: break-word; width: 150.0; height: 150.0">
|
||||
<a href=https://github.com/majst01>
|
||||
<img src=https://avatars.githubusercontent.com/u/410110?v=4 width="100;" style="border-radius:50%;align-items:center;justify-content:center;overflow:hidden;padding-top:10px" alt=Stefan Majer/>
|
||||
<br />
|
||||
<sub style="font-size:14px"><b>Stefan Majer</b></sub>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center" style="word-wrap: break-word; width: 150.0; height: 150.0">
|
||||
<a href=https://github.com/lachy2849>
|
||||
<img src=https://avatars.githubusercontent.com/u/98844035?v=4 width="100;" style="border-radius:50%;align-items:center;justify-content:center;overflow:hidden;padding-top:10px" alt=lachy2849/>
|
||||
<br />
|
||||
<sub style="font-size:14px"><b>lachy2849</b></sub>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center" style="word-wrap: break-word; width: 150.0; height: 150.0">
|
||||
<a href=https://github.com/t56k>
|
||||
<img src=https://avatars.githubusercontent.com/u/12165422?v=4 width="100;" style="border-radius:50%;align-items:center;justify-content:center;overflow:hidden;padding-top:10px" alt=thomas/>
|
||||
<br />
|
||||
<sub style="font-size:14px"><b>thomas</b></sub>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center" style="word-wrap: break-word; width: 150.0; height: 150.0">
|
||||
<a href=https://github.com/aberoham>
|
||||
<img src=https://avatars.githubusercontent.com/u/586805?v=4 width="100;" style="border-radius:50%;align-items:center;justify-content:center;overflow:hidden;padding-top:10px" alt=Abraham Ingersoll/>
|
||||
<br />
|
||||
<sub style="font-size:14px"><b>Abraham Ingersoll</b></sub>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center" style="word-wrap: break-word; width: 150.0; height: 150.0">
|
||||
<a href=https://github.com/artemklevtsov>
|
||||
<img src=https://avatars.githubusercontent.com/u/603798?v=4 width="100;" style="border-radius:50%;align-items:center;justify-content:center;overflow:hidden;padding-top:10px" alt=Artem Klevtsov/>
|
||||
<br />
|
||||
<sub style="font-size:14px"><b>Artem Klevtsov</b></sub>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" style="word-wrap: break-word; width: 150.0; height: 150.0">
|
||||
<a href=https://github.com/awoimbee>
|
||||
<img src=https://avatars.githubusercontent.com/u/22431493?v=4 width="100;" style="border-radius:50%;align-items:center;justify-content:center;overflow:hidden;padding-top:10px" alt=Arthur Woimbée/>
|
||||
<br />
|
||||
<sub style="font-size:14px"><b>Arthur Woimbée</b></sub>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center" style="word-wrap: break-word; width: 150.0; height: 150.0">
|
||||
<a href=https://github.com/stensonb>
|
||||
<img src=https://avatars.githubusercontent.com/u/933389?v=4 width="100;" style="border-radius:50%;align-items:center;justify-content:center;overflow:hidden;padding-top:10px" alt=Bryan Stenson/>
|
||||
<br />
|
||||
<sub style="font-size:14px"><b>Bryan Stenson</b></sub>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center" style="word-wrap: break-word; width: 150.0; height: 150.0">
|
||||
<a href=https://github.com/fkr>
|
||||
<img src=https://avatars.githubusercontent.com/u/51063?v=4 width="100;" style="border-radius:50%;align-items:center;justify-content:center;overflow:hidden;padding-top:10px" alt=Felix Kronlage-Dammers/>
|
||||
<br />
|
||||
<sub style="font-size:14px"><b>Felix Kronlage-Dammers</b></sub>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center" style="word-wrap: break-word; width: 150.0; height: 150.0">
|
||||
<a href=https://github.com/felixonmars>
|
||||
<img src=https://avatars.githubusercontent.com/u/1006477?v=4 width="100;" style="border-radius:50%;align-items:center;justify-content:center;overflow:hidden;padding-top:10px" alt=Felix Yan/>
|
||||
<br />
|
||||
<sub style="font-size:14px"><b>Felix Yan</b></sub>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center" style="word-wrap: break-word; width: 150.0; height: 150.0">
|
||||
<a href=https://github.com/JJGadgets>
|
||||
<img src=https://avatars.githubusercontent.com/u/5709019?v=4 width="100;" style="border-radius:50%;align-items:center;justify-content:center;overflow:hidden;padding-top:10px" alt=JJGadgets/>
|
||||
<br />
|
||||
<sub style="font-size:14px"><b>JJGadgets</b></sub>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center" style="word-wrap: break-word; width: 150.0; height: 150.0">
|
||||
<a href=https://github.com/madjam002>
|
||||
<img src=https://avatars.githubusercontent.com/u/679137?v=4 width="100;" style="border-radius:50%;align-items:center;justify-content:center;overflow:hidden;padding-top:10px" alt=Jamie Greeff/>
|
||||
<br />
|
||||
<sub style="font-size:14px"><b>Jamie Greeff</b></sub>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" style="word-wrap: break-word; width: 150.0; height: 150.0">
|
||||
<a href=https://github.com/jimt>
|
||||
<img src=https://avatars.githubusercontent.com/u/180326?v=4 width="100;" style="border-radius:50%;align-items:center;justify-content:center;overflow:hidden;padding-top:10px" alt=Jim Tittsler/>
|
||||
<br />
|
||||
<sub style="font-size:14px"><b>Jim Tittsler</b></sub>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center" style="word-wrap: break-word; width: 150.0; height: 150.0">
|
||||
<a href=https://github.com/piec>
|
||||
<img src=https://avatars.githubusercontent.com/u/781471?v=4 width="100;" style="border-radius:50%;align-items:center;justify-content:center;overflow:hidden;padding-top:10px" alt=Pierre Carru/>
|
||||
<br />
|
||||
<sub style="font-size:14px"><b>Pierre Carru</b></sub>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center" style="word-wrap: break-word; width: 150.0; height: 150.0">
|
||||
<a href=https://github.com/rcursaru>
|
||||
<img src=https://avatars.githubusercontent.com/u/16259641?v=4 width="100;" style="border-radius:50%;align-items:center;justify-content:center;overflow:hidden;padding-top:10px" alt=rcursaru/>
|
||||
<br />
|
||||
<sub style="font-size:14px"><b>rcursaru</b></sub>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center" style="word-wrap: break-word; width: 150.0; height: 150.0">
|
||||
<a href=https://github.com/renovate-bot>
|
||||
<img src=https://avatars.githubusercontent.com/u/25180681?v=4 width="100;" style="border-radius:50%;align-items:center;justify-content:center;overflow:hidden;padding-top:10px" alt=WhiteSource Renovate/>
|
||||
<br />
|
||||
<sub style="font-size:14px"><b>WhiteSource Renovate</b></sub>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center" style="word-wrap: break-word; width: 150.0; height: 150.0">
|
||||
<a href=https://github.com/ryanfowler>
|
||||
<img src=https://avatars.githubusercontent.com/u/2668821?v=4 width="100;" style="border-radius:50%;align-items:center;justify-content:center;overflow:hidden;padding-top:10px" alt=Ryan Fowler/>
|
||||
<br />
|
||||
<sub style="font-size:14px"><b>Ryan Fowler</b></sub>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center" style="word-wrap: break-word; width: 150.0; height: 150.0">
|
||||
<a href=https://github.com/shaananc>
|
||||
<img src=https://avatars.githubusercontent.com/u/2287839?v=4 width="100;" style="border-radius:50%;align-items:center;justify-content:center;overflow:hidden;padding-top:10px" alt=Shaanan Cohney/>
|
||||
<br />
|
||||
<sub style="font-size:14px"><b>Shaanan Cohney</b></sub>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" style="word-wrap: break-word; width: 150.0; height: 150.0">
|
||||
<a href=https://github.com/m-tanner-dev0>
|
||||
<img src=https://avatars.githubusercontent.com/u/97977342?v=4 width="100;" style="border-radius:50%;align-items:center;justify-content:center;overflow:hidden;padding-top:10px" alt=Tanner/>
|
||||
<br />
|
||||
<sub style="font-size:14px"><b>Tanner</b></sub>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center" style="word-wrap: break-word; width: 150.0; height: 150.0">
|
||||
<a href=https://github.com/Teteros>
|
||||
<img src=https://avatars.githubusercontent.com/u/5067989?v=4 width="100;" style="border-radius:50%;align-items:center;justify-content:center;overflow:hidden;padding-top:10px" alt=Teteros/>
|
||||
<br />
|
||||
<sub style="font-size:14px"><b>Teteros</b></sub>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center" style="word-wrap: break-word; width: 150.0; height: 150.0">
|
||||
<a href=https://github.com/gitter-badger>
|
||||
<img src=https://avatars.githubusercontent.com/u/8518239?v=4 width="100;" style="border-radius:50%;align-items:center;justify-content:center;overflow:hidden;padding-top:10px" alt=The Gitter Badger/>
|
||||
<br />
|
||||
<sub style="font-size:14px"><b>The Gitter Badger</b></sub>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center" style="word-wrap: break-word; width: 150.0; height: 150.0">
|
||||
<a href=https://github.com/tianon>
|
||||
<img src=https://avatars.githubusercontent.com/u/161631?v=4 width="100;" style="border-radius:50%;align-items:center;justify-content:center;overflow:hidden;padding-top:10px" alt=Tianon Gravi/>
|
||||
<br />
|
||||
<sub style="font-size:14px"><b>Tianon Gravi</b></sub>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center" style="word-wrap: break-word; width: 150.0; height: 150.0">
|
||||
<a href=https://github.com/woudsma>
|
||||
<img src=https://avatars.githubusercontent.com/u/6162978?v=4 width="100;" style="border-radius:50%;align-items:center;justify-content:center;overflow:hidden;padding-top:10px" alt=Tjerk Woudsma/>
|
||||
<br />
|
||||
<sub style="font-size:14px"><b>Tjerk Woudsma</b></sub>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center" style="word-wrap: break-word; width: 150.0; height: 150.0">
|
||||
<a href=https://github.com/y0ngb1n>
|
||||
<img src=https://avatars.githubusercontent.com/u/25719408?v=4 width="100;" style="border-radius:50%;align-items:center;justify-content:center;overflow:hidden;padding-top:10px" alt=Yang Bin/>
|
||||
<br />
|
||||
<sub style="font-size:14px"><b>Yang Bin</b></sub>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" style="word-wrap: break-word; width: 150.0; height: 150.0">
|
||||
<a href=https://github.com/zekker6>
|
||||
<img src=https://avatars.githubusercontent.com/u/1367798?v=4 width="100;" style="border-radius:50%;align-items:center;justify-content:center;overflow:hidden;padding-top:10px" alt=Zakhar Bessarab/>
|
||||
<br />
|
||||
<sub style="font-size:14px"><b>Zakhar Bessarab</b></sub>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center" style="word-wrap: break-word; width: 150.0; height: 150.0">
|
||||
<a href=https://github.com/Bpazy>
|
||||
<img src=https://avatars.githubusercontent.com/u/9838749?v=4 width="100;" style="border-radius:50%;align-items:center;justify-content:center;overflow:hidden;padding-top:10px" alt=ZiYuan/>
|
||||
<br />
|
||||
<sub style="font-size:14px"><b>ZiYuan</b></sub>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center" style="word-wrap: break-word; width: 150.0; height: 150.0">
|
||||
<a href=https://github.com/bravechamp>
|
||||
<img src=https://avatars.githubusercontent.com/u/48980452?v=4 width="100;" style="border-radius:50%;align-items:center;justify-content:center;overflow:hidden;padding-top:10px" alt=bravechamp/>
|
||||
<br />
|
||||
<sub style="font-size:14px"><b>bravechamp</b></sub>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center" style="word-wrap: break-word; width: 150.0; height: 150.0">
|
||||
<a href=https://github.com/derelm>
|
||||
<img src=https://avatars.githubusercontent.com/u/465155?v=4 width="100;" style="border-radius:50%;align-items:center;justify-content:center;overflow:hidden;padding-top:10px" alt=derelm/>
|
||||
<br />
|
||||
<sub style="font-size:14px"><b>derelm</b></sub>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center" style="word-wrap: break-word; width: 150.0; height: 150.0">
|
||||
<a href=https://github.com/ignoramous>
|
||||
<img src=https://avatars.githubusercontent.com/u/852289?v=4 width="100;" style="border-radius:50%;align-items:center;justify-content:center;overflow:hidden;padding-top:10px" alt=ignoramous/>
|
||||
<br />
|
||||
<sub style="font-size:14px"><b>ignoramous</b></sub>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center" style="word-wrap: break-word; width: 150.0; height: 150.0">
|
||||
<a href=https://github.com/lion24>
|
||||
<img src=https://avatars.githubusercontent.com/u/1382102?v=4 width="100;" style="border-radius:50%;align-items:center;justify-content:center;overflow:hidden;padding-top:10px" alt=lion24/>
|
||||
<br />
|
||||
<sub style="font-size:14px"><b>lion24</b></sub>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" style="word-wrap: break-word; width: 150.0; height: 150.0">
|
||||
<a href=https://github.com/pernila>
|
||||
<img src=https://avatars.githubusercontent.com/u/12460060?v=4 width="100;" style="border-radius:50%;align-items:center;justify-content:center;overflow:hidden;padding-top:10px" alt=pernila/>
|
||||
<br />
|
||||
<sub style="font-size:14px"><b>pernila</b></sub>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center" style="word-wrap: break-word; width: 150.0; height: 150.0">
|
||||
<a href=https://github.com/Wakeful-Cloud>
|
||||
<img src=https://avatars.githubusercontent.com/u/38930607?v=4 width="100;" style="border-radius:50%;align-items:center;justify-content:center;overflow:hidden;padding-top:10px" alt=Wakeful-Cloud/>
|
||||
<br />
|
||||
<sub style="font-size:14px"><b>Wakeful-Cloud</b></sub>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center" style="word-wrap: break-word; width: 150.0; height: 150.0">
|
||||
<a href=https://github.com/xpzouying>
|
||||
<img src=https://avatars.githubusercontent.com/u/3946563?v=4 width="100;" style="border-radius:50%;align-items:center;justify-content:center;overflow:hidden;padding-top:10px" alt=zy/>
|
||||
<br />
|
||||
<sub style="font-size:14px"><b>zy</b></sub>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
434
acls.go
434
acls.go
@@ -4,26 +4,44 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/rs/zerolog/log"
|
||||
"github.com/tailscale/hujson"
|
||||
"gopkg.in/yaml.v3"
|
||||
"inet.af/netaddr"
|
||||
"tailscale.com/tailcfg"
|
||||
)
|
||||
|
||||
const errorEmptyPolicy = Error("empty policy")
|
||||
const errorInvalidAction = Error("invalid action")
|
||||
const errorInvalidUserSection = Error("invalid user section")
|
||||
const errorInvalidGroup = Error("invalid group")
|
||||
const errorInvalidTag = Error("invalid tag")
|
||||
const errorInvalidNamespace = Error("invalid namespace")
|
||||
const errorInvalidPortFormat = Error("invalid port format")
|
||||
const (
|
||||
errEmptyPolicy = Error("empty policy")
|
||||
errInvalidAction = Error("invalid action")
|
||||
errInvalidGroup = Error("invalid group")
|
||||
errInvalidTag = Error("invalid tag")
|
||||
errInvalidPortFormat = Error("invalid port format")
|
||||
)
|
||||
|
||||
// LoadACLPolicy loads the ACL policy from the specify path, and generates the ACL rules
|
||||
const (
|
||||
Base8 = 8
|
||||
Base10 = 10
|
||||
BitSize16 = 16
|
||||
BitSize32 = 32
|
||||
BitSize64 = 64
|
||||
portRangeBegin = 0
|
||||
portRangeEnd = 65535
|
||||
expectedTokenItems = 2
|
||||
)
|
||||
|
||||
// LoadACLPolicy loads the ACL policy from the specify path, and generates the ACL rules.
|
||||
func (h *Headscale) LoadACLPolicy(path string) error {
|
||||
log.Debug().
|
||||
Str("func", "LoadACLPolicy").
|
||||
Str("path", path).
|
||||
Msg("Loading ACL policy from path")
|
||||
|
||||
policyFile, err := os.Open(path)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -31,56 +49,100 @@ func (h *Headscale) LoadACLPolicy(path string) error {
|
||||
defer policyFile.Close()
|
||||
|
||||
var policy ACLPolicy
|
||||
b, err := io.ReadAll(policyFile)
|
||||
policyBytes, err := io.ReadAll(policyFile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = hujson.Unmarshal(b, &policy)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
switch filepath.Ext(path) {
|
||||
case ".yml", ".yaml":
|
||||
log.Debug().
|
||||
Str("path", path).
|
||||
Bytes("file", policyBytes).
|
||||
Msg("Loading ACLs from YAML")
|
||||
|
||||
err := yaml.Unmarshal(policyBytes, &policy)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
log.Trace().
|
||||
Interface("policy", policy).
|
||||
Msg("Loaded policy from YAML")
|
||||
|
||||
default:
|
||||
ast, err := hujson.Parse(policyBytes)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ast.Standardize()
|
||||
policyBytes = ast.Pack()
|
||||
err = json.Unmarshal(policyBytes, &policy)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if policy.IsZero() {
|
||||
return errorEmptyPolicy
|
||||
return errEmptyPolicy
|
||||
}
|
||||
|
||||
h.aclPolicy = &policy
|
||||
|
||||
return h.UpdateACLRules()
|
||||
}
|
||||
|
||||
func (h *Headscale) UpdateACLRules() error {
|
||||
rules, err := h.generateACLRules()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
log.Trace().Interface("ACL", rules).Msg("ACL rules generated")
|
||||
h.aclRules = rules
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *Headscale) generateACLRules() (*[]tailcfg.FilterRule, error) {
|
||||
func (h *Headscale) generateACLRules() ([]tailcfg.FilterRule, error) {
|
||||
rules := []tailcfg.FilterRule{}
|
||||
|
||||
for i, a := range h.aclPolicy.ACLs {
|
||||
if a.Action != "accept" {
|
||||
return nil, errorInvalidAction
|
||||
}
|
||||
if h.aclPolicy == nil {
|
||||
return nil, errEmptyPolicy
|
||||
}
|
||||
|
||||
r := tailcfg.FilterRule{}
|
||||
machines, err := h.ListMachines()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for index, acl := range h.aclPolicy.ACLs {
|
||||
if acl.Action != "accept" {
|
||||
return nil, errInvalidAction
|
||||
}
|
||||
|
||||
srcIPs := []string{}
|
||||
for j, u := range a.Users {
|
||||
srcs, err := h.generateACLPolicySrcIP(u)
|
||||
for innerIndex, user := range acl.Users {
|
||||
srcs, err := h.generateACLPolicySrcIP(machines, *h.aclPolicy, user)
|
||||
if err != nil {
|
||||
log.Printf("Error parsing ACL %d, User %d", i, j)
|
||||
log.Error().
|
||||
Msgf("Error parsing ACL %d, User %d", index, innerIndex)
|
||||
|
||||
return nil, err
|
||||
}
|
||||
srcIPs = append(srcIPs, *srcs...)
|
||||
srcIPs = append(srcIPs, srcs...)
|
||||
}
|
||||
r.SrcIPs = srcIPs
|
||||
|
||||
destPorts := []tailcfg.NetPortRange{}
|
||||
for j, d := range a.Ports {
|
||||
dests, err := h.generateACLPolicyDestPorts(d)
|
||||
for innerIndex, ports := range acl.Ports {
|
||||
dests, err := h.generateACLPolicyDestPorts(machines, *h.aclPolicy, ports)
|
||||
if err != nil {
|
||||
log.Printf("Error parsing ACL %d, Port %d", i, j)
|
||||
log.Error().
|
||||
Msgf("Error parsing ACL %d, Port %d", index, innerIndex)
|
||||
|
||||
return nil, err
|
||||
}
|
||||
destPorts = append(destPorts, *dests...)
|
||||
destPorts = append(destPorts, dests...)
|
||||
}
|
||||
|
||||
rules = append(rules, tailcfg.FilterRule{
|
||||
@@ -89,17 +151,25 @@ func (h *Headscale) generateACLRules() (*[]tailcfg.FilterRule, error) {
|
||||
})
|
||||
}
|
||||
|
||||
return &rules, nil
|
||||
return rules, nil
|
||||
}
|
||||
|
||||
func (h *Headscale) generateACLPolicySrcIP(u string) (*[]string, error) {
|
||||
return h.expandAlias(u)
|
||||
func (h *Headscale) generateACLPolicySrcIP(
|
||||
machines []Machine,
|
||||
aclPolicy ACLPolicy,
|
||||
u string,
|
||||
) ([]string, error) {
|
||||
return expandAlias(machines, aclPolicy, u, h.cfg.OIDC.StripEmaildomain)
|
||||
}
|
||||
|
||||
func (h *Headscale) generateACLPolicyDestPorts(d string) (*[]tailcfg.NetPortRange, error) {
|
||||
func (h *Headscale) generateACLPolicyDestPorts(
|
||||
machines []Machine,
|
||||
aclPolicy ACLPolicy,
|
||||
d string,
|
||||
) ([]tailcfg.NetPortRange, error) {
|
||||
tokens := strings.Split(d, ":")
|
||||
if len(tokens) < 2 || len(tokens) > 3 {
|
||||
return nil, errorInvalidPortFormat
|
||||
if len(tokens) < expectedTokenItems || len(tokens) > 3 {
|
||||
return nil, errInvalidPortFormat
|
||||
}
|
||||
|
||||
var alias string
|
||||
@@ -109,23 +179,28 @@ func (h *Headscale) generateACLPolicyDestPorts(d string) (*[]tailcfg.NetPortRang
|
||||
// tag:montreal-webserver:80,443
|
||||
// tag:api-server:443
|
||||
// example-host-1:*
|
||||
if len(tokens) == 2 {
|
||||
if len(tokens) == expectedTokenItems {
|
||||
alias = tokens[0]
|
||||
} else {
|
||||
alias = fmt.Sprintf("%s:%s", tokens[0], tokens[1])
|
||||
}
|
||||
|
||||
expanded, err := h.expandAlias(alias)
|
||||
expanded, err := expandAlias(
|
||||
machines,
|
||||
aclPolicy,
|
||||
alias,
|
||||
h.cfg.OIDC.StripEmaildomain,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ports, err := h.expandPorts(tokens[len(tokens)-1])
|
||||
ports, err := expandPorts(tokens[len(tokens)-1])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
dests := []tailcfg.NetPortRange{}
|
||||
for _, d := range *expanded {
|
||||
for _, d := range expanded {
|
||||
for _, p := range *ports {
|
||||
pr := tailcfg.NetPortRange{
|
||||
IP: d,
|
||||
@@ -134,120 +209,160 @@ func (h *Headscale) generateACLPolicyDestPorts(d string) (*[]tailcfg.NetPortRang
|
||||
dests = append(dests, pr)
|
||||
}
|
||||
}
|
||||
return &dests, nil
|
||||
|
||||
return dests, nil
|
||||
}
|
||||
|
||||
func (h *Headscale) expandAlias(s string) (*[]string, error) {
|
||||
if s == "*" {
|
||||
return &[]string{"*"}, nil
|
||||
// expandalias has an input of either
|
||||
// - a namespace
|
||||
// - a group
|
||||
// - a tag
|
||||
// and transform these in IPAddresses.
|
||||
func expandAlias(
|
||||
machines []Machine,
|
||||
aclPolicy ACLPolicy,
|
||||
alias string,
|
||||
stripEmailDomain bool,
|
||||
) ([]string, error) {
|
||||
ips := []string{}
|
||||
if alias == "*" {
|
||||
return []string{"*"}, nil
|
||||
}
|
||||
|
||||
if strings.HasPrefix(s, "group:") {
|
||||
if _, ok := h.aclPolicy.Groups[s]; !ok {
|
||||
return nil, errorInvalidGroup
|
||||
log.Debug().
|
||||
Str("alias", alias).
|
||||
Msg("Expanding")
|
||||
|
||||
if strings.HasPrefix(alias, "group:") {
|
||||
namespaces, err := expandGroup(aclPolicy, alias, stripEmailDomain)
|
||||
if err != nil {
|
||||
return ips, err
|
||||
}
|
||||
ips := []string{}
|
||||
for _, n := range h.aclPolicy.Groups[s] {
|
||||
nodes, err := h.ListMachinesInNamespace(n)
|
||||
if err != nil {
|
||||
return nil, errorInvalidNamespace
|
||||
}
|
||||
for _, node := range *nodes {
|
||||
ips = append(ips, node.IPAddress)
|
||||
for _, n := range namespaces {
|
||||
nodes := filterMachinesByNamespace(machines, n)
|
||||
for _, node := range nodes {
|
||||
ips = append(ips, node.IPAddresses.ToStringSlice()...)
|
||||
}
|
||||
}
|
||||
return &ips, nil
|
||||
|
||||
return ips, nil
|
||||
}
|
||||
|
||||
if strings.HasPrefix(s, "tag:") {
|
||||
if _, ok := h.aclPolicy.TagOwners[s]; !ok {
|
||||
return nil, errorInvalidTag
|
||||
if strings.HasPrefix(alias, "tag:") {
|
||||
owners, err := expandTagOwners(aclPolicy, alias, stripEmailDomain)
|
||||
if err != nil {
|
||||
return ips, err
|
||||
}
|
||||
|
||||
// This will have HORRIBLE performance.
|
||||
// We need to change the data model to better store tags
|
||||
machines := []Machine{}
|
||||
if err := h.db.Where("registered").Find(&machines).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ips := []string{}
|
||||
for _, m := range machines {
|
||||
hostinfo := tailcfg.Hostinfo{}
|
||||
if len(m.HostInfo) != 0 {
|
||||
hi, err := m.HostInfo.MarshalJSON()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
err = json.Unmarshal(hi, &hostinfo)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// FIXME: Check TagOwners allows this
|
||||
for _, t := range hostinfo.RequestTags {
|
||||
if s[4:] == t {
|
||||
ips = append(ips, m.IPAddress)
|
||||
break
|
||||
for _, namespace := range owners {
|
||||
machines := filterMachinesByNamespace(machines, namespace)
|
||||
for _, machine := range machines {
|
||||
hi := machine.GetHostInfo()
|
||||
for _, t := range hi.RequestTags {
|
||||
if alias == t {
|
||||
ips = append(ips, machine.IPAddresses.ToStringSlice()...)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return &ips, nil
|
||||
|
||||
return ips, nil
|
||||
}
|
||||
|
||||
n, err := h.GetNamespace(s)
|
||||
// if alias is a namespace
|
||||
nodes := filterMachinesByNamespace(machines, alias)
|
||||
nodes = excludeCorrectlyTaggedNodes(aclPolicy, nodes, alias)
|
||||
|
||||
for _, n := range nodes {
|
||||
ips = append(ips, n.IPAddresses.ToStringSlice()...)
|
||||
}
|
||||
if len(ips) > 0 {
|
||||
return ips, nil
|
||||
}
|
||||
|
||||
// if alias is an host
|
||||
if h, ok := aclPolicy.Hosts[alias]; ok {
|
||||
return []string{h.String()}, nil
|
||||
}
|
||||
|
||||
// if alias is an IP
|
||||
ip, err := netaddr.ParseIP(alias)
|
||||
if err == nil {
|
||||
nodes, err := h.ListMachinesInNamespace(n.Name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ips := []string{}
|
||||
for _, n := range *nodes {
|
||||
ips = append(ips, n.IPAddress)
|
||||
}
|
||||
return &ips, nil
|
||||
return []string{ip.String()}, nil
|
||||
}
|
||||
|
||||
if h, ok := h.aclPolicy.Hosts[s]; ok {
|
||||
return &[]string{h.String()}, nil
|
||||
}
|
||||
|
||||
ip, err := netaddr.ParseIP(s)
|
||||
// if alias is an CIDR
|
||||
cidr, err := netaddr.ParseIPPrefix(alias)
|
||||
if err == nil {
|
||||
return &[]string{ip.String()}, nil
|
||||
return []string{cidr.String()}, nil
|
||||
}
|
||||
|
||||
cidr, err := netaddr.ParseIPPrefix(s)
|
||||
if err == nil {
|
||||
return &[]string{cidr.String()}, nil
|
||||
}
|
||||
log.Warn().Msgf("No IPs found with the alias %v", alias)
|
||||
|
||||
return nil, errorInvalidUserSection
|
||||
return ips, nil
|
||||
}
|
||||
|
||||
func (h *Headscale) expandPorts(s string) (*[]tailcfg.PortRange, error) {
|
||||
if s == "*" {
|
||||
return &[]tailcfg.PortRange{{First: 0, Last: 65535}}, nil
|
||||
// excludeCorrectlyTaggedNodes will remove from the list of input nodes the ones
|
||||
// that are correctly tagged since they should not be listed as being in the namespace
|
||||
// we assume in this function that we only have nodes from 1 namespace.
|
||||
func excludeCorrectlyTaggedNodes(
|
||||
aclPolicy ACLPolicy,
|
||||
nodes []Machine,
|
||||
namespace string,
|
||||
) []Machine {
|
||||
out := []Machine{}
|
||||
tags := []string{}
|
||||
for tag, ns := range aclPolicy.TagOwners {
|
||||
if containsString(ns, namespace) {
|
||||
tags = append(tags, tag)
|
||||
}
|
||||
}
|
||||
// for each machine if tag is in tags list, don't append it.
|
||||
for _, machine := range nodes {
|
||||
hi := machine.GetHostInfo()
|
||||
|
||||
found := false
|
||||
for _, t := range hi.RequestTags {
|
||||
if containsString(tags, t) {
|
||||
found = true
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
out = append(out, machine)
|
||||
}
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
func expandPorts(portsStr string) (*[]tailcfg.PortRange, error) {
|
||||
if portsStr == "*" {
|
||||
return &[]tailcfg.PortRange{
|
||||
{First: portRangeBegin, Last: portRangeEnd},
|
||||
}, nil
|
||||
}
|
||||
|
||||
ports := []tailcfg.PortRange{}
|
||||
for _, p := range strings.Split(s, ",") {
|
||||
rang := strings.Split(p, "-")
|
||||
if len(rang) == 1 {
|
||||
pi, err := strconv.ParseUint(rang[0], 10, 16)
|
||||
for _, portStr := range strings.Split(portsStr, ",") {
|
||||
rang := strings.Split(portStr, "-")
|
||||
switch len(rang) {
|
||||
case 1:
|
||||
port, err := strconv.ParseUint(rang[0], Base10, BitSize16)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ports = append(ports, tailcfg.PortRange{
|
||||
First: uint16(pi),
|
||||
Last: uint16(pi),
|
||||
First: uint16(port),
|
||||
Last: uint16(port),
|
||||
})
|
||||
} else if len(rang) == 2 {
|
||||
start, err := strconv.ParseUint(rang[0], 10, 16)
|
||||
|
||||
case expectedTokenItems:
|
||||
start, err := strconv.ParseUint(rang[0], Base10, BitSize16)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
last, err := strconv.ParseUint(rang[1], 10, 16)
|
||||
last, err := strconv.ParseUint(rang[1], Base10, BitSize16)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -255,9 +370,90 @@ func (h *Headscale) expandPorts(s string) (*[]tailcfg.PortRange, error) {
|
||||
First: uint16(start),
|
||||
Last: uint16(last),
|
||||
})
|
||||
} else {
|
||||
return nil, errorInvalidPortFormat
|
||||
|
||||
default:
|
||||
return nil, errInvalidPortFormat
|
||||
}
|
||||
}
|
||||
|
||||
return &ports, nil
|
||||
}
|
||||
|
||||
func filterMachinesByNamespace(machines []Machine, namespace string) []Machine {
|
||||
out := []Machine{}
|
||||
for _, machine := range machines {
|
||||
if machine.Namespace.Name == namespace {
|
||||
out = append(out, machine)
|
||||
}
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
// expandTagOwners will return a list of namespace. An owner can be either a namespace or a group
|
||||
// a group cannot be composed of groups.
|
||||
func expandTagOwners(
|
||||
aclPolicy ACLPolicy,
|
||||
tag string,
|
||||
stripEmailDomain bool,
|
||||
) ([]string, error) {
|
||||
var owners []string
|
||||
ows, ok := aclPolicy.TagOwners[tag]
|
||||
if !ok {
|
||||
return []string{}, fmt.Errorf(
|
||||
"%w. %v isn't owned by a TagOwner. Please add one first. https://tailscale.com/kb/1018/acls/#tag-owners",
|
||||
errInvalidTag,
|
||||
tag,
|
||||
)
|
||||
}
|
||||
for _, owner := range ows {
|
||||
if strings.HasPrefix(owner, "group:") {
|
||||
gs, err := expandGroup(aclPolicy, owner, stripEmailDomain)
|
||||
if err != nil {
|
||||
return []string{}, err
|
||||
}
|
||||
owners = append(owners, gs...)
|
||||
} else {
|
||||
owners = append(owners, owner)
|
||||
}
|
||||
}
|
||||
|
||||
return owners, nil
|
||||
}
|
||||
|
||||
// expandGroup will return the list of namespace inside the group
|
||||
// after some validation.
|
||||
func expandGroup(
|
||||
aclPolicy ACLPolicy,
|
||||
group string,
|
||||
stripEmailDomain bool,
|
||||
) ([]string, error) {
|
||||
outGroups := []string{}
|
||||
aclGroups, ok := aclPolicy.Groups[group]
|
||||
if !ok {
|
||||
return []string{}, fmt.Errorf(
|
||||
"group %v isn't registered. %w",
|
||||
group,
|
||||
errInvalidGroup,
|
||||
)
|
||||
}
|
||||
for _, group := range aclGroups {
|
||||
if strings.HasPrefix(group, "group:") {
|
||||
return []string{}, fmt.Errorf(
|
||||
"%w. A group cannot be composed of groups. https://tailscale.com/kb/1018/acls/#groups",
|
||||
errInvalidGroup,
|
||||
)
|
||||
}
|
||||
grp, err := NormalizeToFQDNRules(group, stripEmailDomain)
|
||||
if err != nil {
|
||||
return []string{}, fmt.Errorf(
|
||||
"failed to normalize group %q, err: %w",
|
||||
group,
|
||||
errInvalidGroup,
|
||||
)
|
||||
}
|
||||
outGroups = append(outGroups, grp)
|
||||
}
|
||||
|
||||
return outGroups, nil
|
||||
}
|
||||
|
||||
1202
acls_test.go
1202
acls_test.go
File diff suppressed because it is too large
Load Diff
@@ -1,70 +1,101 @@
|
||||
package headscale
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
|
||||
"github.com/tailscale/hujson"
|
||||
"gopkg.in/yaml.v3"
|
||||
"inet.af/netaddr"
|
||||
)
|
||||
|
||||
// ACLPolicy represents a Tailscale ACL Policy
|
||||
// ACLPolicy represents a Tailscale ACL Policy.
|
||||
type ACLPolicy struct {
|
||||
Groups Groups `json:"Groups"`
|
||||
Hosts Hosts `json:"Hosts"`
|
||||
TagOwners TagOwners `json:"TagOwners"`
|
||||
ACLs []ACL `json:"ACLs"`
|
||||
Tests []ACLTest `json:"Tests"`
|
||||
Groups Groups `json:"Groups" yaml:"Groups"`
|
||||
Hosts Hosts `json:"Hosts" yaml:"Hosts"`
|
||||
TagOwners TagOwners `json:"TagOwners" yaml:"TagOwners"`
|
||||
ACLs []ACL `json:"ACLs" yaml:"ACLs"`
|
||||
Tests []ACLTest `json:"Tests" yaml:"Tests"`
|
||||
}
|
||||
|
||||
// ACL is a basic rule for the ACL Policy
|
||||
// ACL is a basic rule for the ACL Policy.
|
||||
type ACL struct {
|
||||
Action string `json:"Action"`
|
||||
Users []string `json:"Users"`
|
||||
Ports []string `json:"Ports"`
|
||||
Action string `json:"Action" yaml:"Action"`
|
||||
Users []string `json:"Users" yaml:"Users"`
|
||||
Ports []string `json:"Ports" yaml:"Ports"`
|
||||
}
|
||||
|
||||
// Groups references a series of alias in the ACL rules
|
||||
// Groups references a series of alias in the ACL rules.
|
||||
type Groups map[string][]string
|
||||
|
||||
// Hosts are alias for IP addresses or subnets
|
||||
// Hosts are alias for IP addresses or subnets.
|
||||
type Hosts map[string]netaddr.IPPrefix
|
||||
|
||||
// TagOwners specify what users (namespaces?) are allow to use certain tags
|
||||
// TagOwners specify what users (namespaces?) are allow to use certain tags.
|
||||
type TagOwners map[string][]string
|
||||
|
||||
// ACLTest is not implemented, but should be use to check if a certain rule is allowed
|
||||
// ACLTest is not implemented, but should be use to check if a certain rule is allowed.
|
||||
type ACLTest struct {
|
||||
User string `json:"User"`
|
||||
Allow []string `json:"Allow"`
|
||||
Deny []string `json:"Deny,omitempty"`
|
||||
User string `json:"User" yaml:"User"`
|
||||
Allow []string `json:"Allow" yaml:"Allow"`
|
||||
Deny []string `json:"Deny,omitempty" yaml:"Deny,omitempty"`
|
||||
}
|
||||
|
||||
// UnmarshalJSON allows to parse the Hosts directly into netaddr objects
|
||||
func (h *Hosts) UnmarshalJSON(data []byte) error {
|
||||
hosts := Hosts{}
|
||||
hs := make(map[string]string)
|
||||
err := hujson.Unmarshal(data, &hs)
|
||||
// UnmarshalJSON allows to parse the Hosts directly into netaddr objects.
|
||||
func (hosts *Hosts) UnmarshalJSON(data []byte) error {
|
||||
newHosts := Hosts{}
|
||||
hostIPPrefixMap := make(map[string]string)
|
||||
ast, err := hujson.Parse(data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for k, v := range hs {
|
||||
if !strings.Contains(v, "/") {
|
||||
v = v + "/32"
|
||||
ast.Standardize()
|
||||
data = ast.Pack()
|
||||
err = json.Unmarshal(data, &hostIPPrefixMap)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for host, prefixStr := range hostIPPrefixMap {
|
||||
if !strings.Contains(prefixStr, "/") {
|
||||
prefixStr += "/32"
|
||||
}
|
||||
prefix, err := netaddr.ParseIPPrefix(v)
|
||||
prefix, err := netaddr.ParseIPPrefix(prefixStr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
hosts[k] = prefix
|
||||
newHosts[host] = prefix
|
||||
}
|
||||
*h = hosts
|
||||
*hosts = newHosts
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsZero is perhaps a bit naive here
|
||||
func (p ACLPolicy) IsZero() bool {
|
||||
if len(p.Groups) == 0 && len(p.Hosts) == 0 && len(p.ACLs) == 0 {
|
||||
// UnmarshalYAML allows to parse the Hosts directly into netaddr objects.
|
||||
func (hosts *Hosts) UnmarshalYAML(data []byte) error {
|
||||
newHosts := Hosts{}
|
||||
hostIPPrefixMap := make(map[string]string)
|
||||
|
||||
err := yaml.Unmarshal(data, &hostIPPrefixMap)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for host, prefixStr := range hostIPPrefixMap {
|
||||
prefix, err := netaddr.ParseIPPrefix(prefixStr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
newHosts[host] = prefix
|
||||
}
|
||||
*hosts = newHosts
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsZero is perhaps a bit naive here.
|
||||
func (policy ACLPolicy) IsZero() bool {
|
||||
if len(policy.Groups) == 0 && len(policy.Hosts) == 0 && len(policy.ACLs) == 0 {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
164
api_key.go
Normal file
164
api_key.go
Normal file
@@ -0,0 +1,164 @@
|
||||
package headscale
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
v1 "github.com/juanfont/headscale/gen/go/headscale/v1"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"google.golang.org/protobuf/types/known/timestamppb"
|
||||
)
|
||||
|
||||
const (
|
||||
apiPrefixLength = 7
|
||||
apiKeyLength = 32
|
||||
apiKeyParts = 2
|
||||
|
||||
errAPIKeyFailedToParse = Error("Failed to parse ApiKey")
|
||||
)
|
||||
|
||||
// APIKey describes the datamodel for API keys used to remotely authenticate with
|
||||
// headscale.
|
||||
type APIKey struct {
|
||||
ID uint64 `gorm:"primary_key"`
|
||||
Prefix string `gorm:"uniqueIndex"`
|
||||
Hash []byte
|
||||
|
||||
CreatedAt *time.Time
|
||||
Expiration *time.Time
|
||||
LastSeen *time.Time
|
||||
}
|
||||
|
||||
// CreateAPIKey creates a new ApiKey in a namespace, and returns it.
|
||||
func (h *Headscale) CreateAPIKey(
|
||||
expiration *time.Time,
|
||||
) (string, *APIKey, error) {
|
||||
prefix, err := GenerateRandomStringURLSafe(apiPrefixLength)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
|
||||
toBeHashed, err := GenerateRandomStringURLSafe(apiKeyLength)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
|
||||
// Key to return to user, this will only be visible _once_
|
||||
keyStr := prefix + "." + toBeHashed
|
||||
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(toBeHashed), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
|
||||
key := APIKey{
|
||||
Prefix: prefix,
|
||||
Hash: hash,
|
||||
Expiration: expiration,
|
||||
}
|
||||
h.db.Save(&key)
|
||||
|
||||
return keyStr, &key, nil
|
||||
}
|
||||
|
||||
// ListAPIKeys returns the list of ApiKeys for a namespace.
|
||||
func (h *Headscale) ListAPIKeys() ([]APIKey, error) {
|
||||
keys := []APIKey{}
|
||||
if err := h.db.Find(&keys).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return keys, nil
|
||||
}
|
||||
|
||||
// GetAPIKey returns a ApiKey for a given key.
|
||||
func (h *Headscale) GetAPIKey(prefix string) (*APIKey, error) {
|
||||
key := APIKey{}
|
||||
if result := h.db.First(&key, "prefix = ?", prefix); result.Error != nil {
|
||||
return nil, result.Error
|
||||
}
|
||||
|
||||
return &key, nil
|
||||
}
|
||||
|
||||
// GetAPIKeyByID returns a ApiKey for a given id.
|
||||
func (h *Headscale) GetAPIKeyByID(id uint64) (*APIKey, error) {
|
||||
key := APIKey{}
|
||||
if result := h.db.Find(&APIKey{ID: id}).First(&key); result.Error != nil {
|
||||
return nil, result.Error
|
||||
}
|
||||
|
||||
return &key, nil
|
||||
}
|
||||
|
||||
// DestroyAPIKey destroys a ApiKey. Returns error if the ApiKey
|
||||
// does not exist.
|
||||
func (h *Headscale) DestroyAPIKey(key APIKey) error {
|
||||
if result := h.db.Unscoped().Delete(key); result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ExpireAPIKey marks a ApiKey as expired.
|
||||
func (h *Headscale) ExpireAPIKey(key *APIKey) error {
|
||||
if err := h.db.Model(&key).Update("Expiration", time.Now()).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *Headscale) ValidateAPIKey(keyStr string) (bool, error) {
|
||||
prefix, hash, err := splitAPIKey(keyStr)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("failed to validate api key: %w", err)
|
||||
}
|
||||
|
||||
key, err := h.GetAPIKey(prefix)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("failed to validate api key: %w", err)
|
||||
}
|
||||
|
||||
if key.Expiration.Before(time.Now()) {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
if err := bcrypt.CompareHashAndPassword(key.Hash, []byte(hash)); err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func splitAPIKey(key string) (string, string, error) {
|
||||
parts := strings.Split(key, ".")
|
||||
if len(parts) != apiKeyParts {
|
||||
return "", "", errAPIKeyFailedToParse
|
||||
}
|
||||
|
||||
return parts[0], parts[1], nil
|
||||
}
|
||||
|
||||
func (key *APIKey) toProto() *v1.ApiKey {
|
||||
protoKey := v1.ApiKey{
|
||||
Id: key.ID,
|
||||
Prefix: key.Prefix,
|
||||
}
|
||||
|
||||
if key.Expiration != nil {
|
||||
protoKey.Expiration = timestamppb.New(*key.Expiration)
|
||||
}
|
||||
|
||||
if key.CreatedAt != nil {
|
||||
protoKey.CreatedAt = timestamppb.New(*key.CreatedAt)
|
||||
}
|
||||
|
||||
if key.LastSeen != nil {
|
||||
protoKey.LastSeen = timestamppb.New(*key.LastSeen)
|
||||
}
|
||||
|
||||
return &protoKey
|
||||
}
|
||||
89
api_key_test.go
Normal file
89
api_key_test.go
Normal file
@@ -0,0 +1,89 @@
|
||||
package headscale
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gopkg.in/check.v1"
|
||||
)
|
||||
|
||||
func (*Suite) TestCreateAPIKey(c *check.C) {
|
||||
apiKeyStr, apiKey, err := app.CreateAPIKey(nil)
|
||||
c.Assert(err, check.IsNil)
|
||||
c.Assert(apiKey, check.NotNil)
|
||||
|
||||
// Did we get a valid key?
|
||||
c.Assert(apiKey.Prefix, check.NotNil)
|
||||
c.Assert(apiKey.Hash, check.NotNil)
|
||||
c.Assert(apiKeyStr, check.Not(check.Equals), "")
|
||||
|
||||
_, err = app.ListAPIKeys()
|
||||
c.Assert(err, check.IsNil)
|
||||
|
||||
keys, err := app.ListAPIKeys()
|
||||
c.Assert(err, check.IsNil)
|
||||
c.Assert(len(keys), check.Equals, 1)
|
||||
}
|
||||
|
||||
func (*Suite) TestAPIKeyDoesNotExist(c *check.C) {
|
||||
key, err := app.GetAPIKey("does-not-exist")
|
||||
c.Assert(err, check.NotNil)
|
||||
c.Assert(key, check.IsNil)
|
||||
}
|
||||
|
||||
func (*Suite) TestValidateAPIKeyOk(c *check.C) {
|
||||
nowPlus2 := time.Now().Add(2 * time.Hour)
|
||||
apiKeyStr, apiKey, err := app.CreateAPIKey(&nowPlus2)
|
||||
c.Assert(err, check.IsNil)
|
||||
c.Assert(apiKey, check.NotNil)
|
||||
|
||||
valid, err := app.ValidateAPIKey(apiKeyStr)
|
||||
c.Assert(err, check.IsNil)
|
||||
c.Assert(valid, check.Equals, true)
|
||||
}
|
||||
|
||||
func (*Suite) TestValidateAPIKeyNotOk(c *check.C) {
|
||||
nowMinus2 := time.Now().Add(time.Duration(-2) * time.Hour)
|
||||
apiKeyStr, apiKey, err := app.CreateAPIKey(&nowMinus2)
|
||||
c.Assert(err, check.IsNil)
|
||||
c.Assert(apiKey, check.NotNil)
|
||||
|
||||
valid, err := app.ValidateAPIKey(apiKeyStr)
|
||||
c.Assert(err, check.IsNil)
|
||||
c.Assert(valid, check.Equals, false)
|
||||
|
||||
now := time.Now()
|
||||
apiKeyStrNow, apiKey, err := app.CreateAPIKey(&now)
|
||||
c.Assert(err, check.IsNil)
|
||||
c.Assert(apiKey, check.NotNil)
|
||||
|
||||
validNow, err := app.ValidateAPIKey(apiKeyStrNow)
|
||||
c.Assert(err, check.IsNil)
|
||||
c.Assert(validNow, check.Equals, false)
|
||||
|
||||
validSilly, err := app.ValidateAPIKey("nota.validkey")
|
||||
c.Assert(err, check.NotNil)
|
||||
c.Assert(validSilly, check.Equals, false)
|
||||
|
||||
validWithErr, err := app.ValidateAPIKey("produceerrorkey")
|
||||
c.Assert(err, check.NotNil)
|
||||
c.Assert(validWithErr, check.Equals, false)
|
||||
}
|
||||
|
||||
func (*Suite) TestExpireAPIKey(c *check.C) {
|
||||
nowPlus2 := time.Now().Add(2 * time.Hour)
|
||||
apiKeyStr, apiKey, err := app.CreateAPIKey(&nowPlus2)
|
||||
c.Assert(err, check.IsNil)
|
||||
c.Assert(apiKey, check.NotNil)
|
||||
|
||||
valid, err := app.ValidateAPIKey(apiKeyStr)
|
||||
c.Assert(err, check.IsNil)
|
||||
c.Assert(valid, check.Equals, true)
|
||||
|
||||
err = app.ExpireAPIKey(apiKey)
|
||||
c.Assert(err, check.IsNil)
|
||||
c.Assert(apiKey.Expiration, check.NotNil)
|
||||
|
||||
notValid, err := app.ValidateAPIKey(apiKeyStr)
|
||||
c.Assert(err, check.IsNil)
|
||||
c.Assert(notValid, check.Equals, false)
|
||||
}
|
||||
825
app.go
825
app.go
@@ -1,29 +1,89 @@
|
||||
package headscale
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"io"
|
||||
"io/fs"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/signal"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/coreos/go-oidc/v3/oidc"
|
||||
"github.com/gin-gonic/gin"
|
||||
grpc_middleware "github.com/grpc-ecosystem/go-grpc-middleware"
|
||||
"github.com/grpc-ecosystem/grpc-gateway/v2/runtime"
|
||||
v1 "github.com/juanfont/headscale/gen/go/headscale/v1"
|
||||
"github.com/patrickmn/go-cache"
|
||||
zerolog "github.com/philip-bui/grpc-zerolog"
|
||||
zl "github.com/rs/zerolog"
|
||||
"github.com/rs/zerolog/log"
|
||||
ginprometheus "github.com/zsais/go-gin-prometheus"
|
||||
"golang.org/x/crypto/acme"
|
||||
"golang.org/x/crypto/acme/autocert"
|
||||
"golang.org/x/oauth2"
|
||||
"golang.org/x/sync/errgroup"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/credentials"
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
"google.golang.org/grpc/metadata"
|
||||
"google.golang.org/grpc/peer"
|
||||
"google.golang.org/grpc/reflection"
|
||||
"google.golang.org/grpc/status"
|
||||
"gorm.io/gorm"
|
||||
"inet.af/netaddr"
|
||||
"tailscale.com/tailcfg"
|
||||
"tailscale.com/types/wgkey"
|
||||
"tailscale.com/types/dnstype"
|
||||
"tailscale.com/types/key"
|
||||
)
|
||||
|
||||
// Config contains the initial Headscale configuration
|
||||
const (
|
||||
errSTUNAddressNotSet = Error("STUN address not set")
|
||||
errUnsupportedDatabase = Error("unsupported DB")
|
||||
errUnsupportedLetsEncryptChallengeType = Error(
|
||||
"unknown value for Lets Encrypt challenge type",
|
||||
)
|
||||
)
|
||||
|
||||
const (
|
||||
AuthPrefix = "Bearer "
|
||||
Postgres = "postgres"
|
||||
Sqlite = "sqlite3"
|
||||
updateInterval = 5000
|
||||
HTTPReadTimeout = 30 * time.Second
|
||||
privateKeyFileMode = 0o600
|
||||
|
||||
registerCacheExpiration = time.Minute * 15
|
||||
registerCacheCleanup = time.Minute * 20
|
||||
|
||||
DisabledClientAuth = "disabled"
|
||||
RelaxedClientAuth = "relaxed"
|
||||
EnforcedClientAuth = "enforced"
|
||||
)
|
||||
|
||||
// Config contains the initial Headscale configuration.
|
||||
type Config struct {
|
||||
ServerURL string
|
||||
Addr string
|
||||
PrivateKeyPath string
|
||||
DerpMap *tailcfg.DERPMap
|
||||
MetricsAddr string
|
||||
GRPCAddr string
|
||||
GRPCAllowInsecure bool
|
||||
EphemeralNodeInactivityTimeout time.Duration
|
||||
IPPrefixes []netaddr.IPPrefix
|
||||
PrivateKeyPath string
|
||||
BaseDomain string
|
||||
|
||||
DERP DERPConfig
|
||||
|
||||
DBtype string
|
||||
DBpath string
|
||||
@@ -33,81 +93,180 @@ type Config struct {
|
||||
DBuser string
|
||||
DBpass string
|
||||
|
||||
TLSLetsEncryptListen string
|
||||
TLSLetsEncryptHostname string
|
||||
TLSLetsEncryptCacheDir string
|
||||
TLSLetsEncryptChallengeType string
|
||||
|
||||
TLSCertPath string
|
||||
TLSKeyPath string
|
||||
TLSCertPath string
|
||||
TLSKeyPath string
|
||||
TLSClientAuthMode tls.ClientAuthType
|
||||
|
||||
ACMEURL string
|
||||
ACMEEmail string
|
||||
|
||||
DNSConfig *tailcfg.DNSConfig
|
||||
|
||||
UnixSocket string
|
||||
UnixSocketPermission fs.FileMode
|
||||
|
||||
OIDC OIDCConfig
|
||||
|
||||
CLI CLIConfig
|
||||
}
|
||||
|
||||
// Headscale represents the base app of the service
|
||||
type OIDCConfig struct {
|
||||
Issuer string
|
||||
ClientID string
|
||||
ClientSecret string
|
||||
StripEmaildomain bool
|
||||
}
|
||||
|
||||
type DERPConfig struct {
|
||||
ServerEnabled bool
|
||||
ServerRegionID int
|
||||
ServerRegionCode string
|
||||
ServerRegionName string
|
||||
STUNAddr string
|
||||
URLs []url.URL
|
||||
Paths []string
|
||||
AutoUpdate bool
|
||||
UpdateFrequency time.Duration
|
||||
}
|
||||
|
||||
type CLIConfig struct {
|
||||
Address string
|
||||
APIKey string
|
||||
Timeout time.Duration
|
||||
Insecure bool
|
||||
}
|
||||
|
||||
// Headscale represents the base app of the service.
|
||||
type Headscale struct {
|
||||
cfg Config
|
||||
db *gorm.DB
|
||||
dbString string
|
||||
dbType string
|
||||
dbDebug bool
|
||||
publicKey *wgkey.Key
|
||||
privateKey *wgkey.Private
|
||||
privateKey *key.MachinePrivate
|
||||
|
||||
DERPMap *tailcfg.DERPMap
|
||||
DERPServer *DERPServer
|
||||
|
||||
aclPolicy *ACLPolicy
|
||||
aclRules *[]tailcfg.FilterRule
|
||||
aclRules []tailcfg.FilterRule
|
||||
|
||||
pollMu sync.Mutex
|
||||
clientsPolling map[uint64]chan []byte // this is by all means a hackity hack
|
||||
lastStateChange sync.Map
|
||||
|
||||
oidcProvider *oidc.Provider
|
||||
oauth2Config *oauth2.Config
|
||||
|
||||
registrationCache *cache.Cache
|
||||
|
||||
ipAllocationMutex sync.Mutex
|
||||
}
|
||||
|
||||
// Look up the TLS constant relative to user-supplied TLS client
|
||||
// authentication mode. If an unknown mode is supplied, the default
|
||||
// value, tls.RequireAnyClientCert, is returned. The returned boolean
|
||||
// indicates if the supplied mode was valid.
|
||||
func LookupTLSClientAuthMode(mode string) (tls.ClientAuthType, bool) {
|
||||
switch mode {
|
||||
case DisabledClientAuth:
|
||||
// Client cert is _not_ required.
|
||||
return tls.NoClientCert, true
|
||||
case RelaxedClientAuth:
|
||||
// Client cert required, but _not verified_.
|
||||
return tls.RequireAnyClientCert, true
|
||||
case EnforcedClientAuth:
|
||||
// Client cert is _required and verified_.
|
||||
return tls.RequireAndVerifyClientCert, true
|
||||
default:
|
||||
// Return the default when an unknown value is supplied.
|
||||
return tls.RequireAnyClientCert, false
|
||||
}
|
||||
}
|
||||
|
||||
// NewHeadscale returns the Headscale app
|
||||
func NewHeadscale(cfg Config) (*Headscale, error) {
|
||||
content, err := os.ReadFile(cfg.PrivateKeyPath)
|
||||
privKey, err := readOrCreatePrivateKey(cfg.PrivateKeyPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("failed to read or create private key: %w", err)
|
||||
}
|
||||
privKey, err := wgkey.ParsePrivate(string(content))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pubKey := privKey.Public()
|
||||
|
||||
var dbString string
|
||||
switch cfg.DBtype {
|
||||
case "postgres":
|
||||
dbString = fmt.Sprintf("host=%s port=%d dbname=%s user=%s password=%s sslmode=disable", cfg.DBhost,
|
||||
cfg.DBport, cfg.DBname, cfg.DBuser, cfg.DBpass)
|
||||
case "sqlite3":
|
||||
case Postgres:
|
||||
dbString = fmt.Sprintf(
|
||||
"host=%s port=%d dbname=%s user=%s password=%s sslmode=disable",
|
||||
cfg.DBhost,
|
||||
cfg.DBport,
|
||||
cfg.DBname,
|
||||
cfg.DBuser,
|
||||
cfg.DBpass,
|
||||
)
|
||||
case Sqlite:
|
||||
dbString = cfg.DBpath
|
||||
default:
|
||||
return nil, errors.New("Unsupported DB")
|
||||
return nil, errUnsupportedDatabase
|
||||
}
|
||||
|
||||
h := Headscale{
|
||||
cfg: cfg,
|
||||
dbType: cfg.DBtype,
|
||||
dbString: dbString,
|
||||
privateKey: privKey,
|
||||
publicKey: &pubKey,
|
||||
aclRules: &tailcfg.FilterAllowAll, // default allowall
|
||||
registrationCache := cache.New(
|
||||
registerCacheExpiration,
|
||||
registerCacheCleanup,
|
||||
)
|
||||
|
||||
app := Headscale{
|
||||
cfg: cfg,
|
||||
dbType: cfg.DBtype,
|
||||
dbString: dbString,
|
||||
privateKey: privKey,
|
||||
aclRules: tailcfg.FilterAllowAll, // default allowall
|
||||
registrationCache: registrationCache,
|
||||
}
|
||||
|
||||
err = h.initDB()
|
||||
err = app.initDB()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
h.clientsPolling = make(map[uint64]chan []byte)
|
||||
return &h, nil
|
||||
if cfg.OIDC.Issuer != "" {
|
||||
err = app.initOIDC()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
if app.cfg.DNSConfig != nil && app.cfg.DNSConfig.Proxied { // if MagicDNS
|
||||
magicDNSDomains := generateMagicDNSRootDomains(app.cfg.IPPrefixes)
|
||||
// we might have routes already from Split DNS
|
||||
if app.cfg.DNSConfig.Routes == nil {
|
||||
app.cfg.DNSConfig.Routes = make(map[string][]dnstype.Resolver)
|
||||
}
|
||||
for _, d := range magicDNSDomains {
|
||||
app.cfg.DNSConfig.Routes[d.WithoutTrailingDot()] = nil
|
||||
}
|
||||
}
|
||||
|
||||
if cfg.DERP.ServerEnabled {
|
||||
embeddedDERPServer, err := app.NewDERPServer()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
app.DERPServer = embeddedDERPServer
|
||||
}
|
||||
|
||||
return &app, nil
|
||||
}
|
||||
|
||||
// Redirect to our TLS url
|
||||
// Redirect to our TLS url.
|
||||
func (h *Headscale) redirect(w http.ResponseWriter, req *http.Request) {
|
||||
target := h.cfg.ServerURL + req.URL.RequestURI()
|
||||
http.Redirect(w, req, target, http.StatusFound)
|
||||
}
|
||||
|
||||
// ExpireEphemeralNodes deletes ephemeral machine records that have not been
|
||||
// seen for longer than h.cfg.EphemeralNodeInactivityTimeout
|
||||
func (h *Headscale) ExpireEphemeralNodes(milliSeconds int64) {
|
||||
// expireEphemeralNodes deletes ephemeral machine records that have not been
|
||||
// seen for longer than h.cfg.EphemeralNodeInactivityTimeout.
|
||||
func (h *Headscale) expireEphemeralNodes(milliSeconds int64) {
|
||||
ticker := time.NewTicker(time.Duration(milliSeconds) * time.Millisecond)
|
||||
for range ticker.C {
|
||||
h.expireEphemeralNodesWorker()
|
||||
@@ -117,76 +276,592 @@ func (h *Headscale) ExpireEphemeralNodes(milliSeconds int64) {
|
||||
func (h *Headscale) expireEphemeralNodesWorker() {
|
||||
namespaces, err := h.ListNamespaces()
|
||||
if err != nil {
|
||||
log.Printf("Error listing namespaces: %s", err)
|
||||
log.Error().Err(err).Msg("Error listing namespaces")
|
||||
|
||||
return
|
||||
}
|
||||
for _, ns := range *namespaces {
|
||||
machines, err := h.ListMachinesInNamespace(ns.Name)
|
||||
|
||||
for _, namespace := range namespaces {
|
||||
machines, err := h.ListMachinesInNamespace(namespace.Name)
|
||||
if err != nil {
|
||||
log.Printf("Error listing machines in namespace %s: %s", ns.Name, err)
|
||||
log.Error().
|
||||
Err(err).
|
||||
Str("namespace", namespace.Name).
|
||||
Msg("Error listing machines in namespace")
|
||||
|
||||
return
|
||||
}
|
||||
for _, m := range *machines {
|
||||
if m.AuthKey != nil && m.LastSeen != nil && m.AuthKey.Ephemeral && time.Now().After(m.LastSeen.Add(h.cfg.EphemeralNodeInactivityTimeout)) {
|
||||
log.Printf("[%s] Ephemeral client removed from database\n", m.Name)
|
||||
err = h.db.Unscoped().Delete(m).Error
|
||||
|
||||
for _, machine := range machines {
|
||||
if machine.AuthKey != nil && machine.LastSeen != nil &&
|
||||
machine.AuthKey.Ephemeral &&
|
||||
time.Now().
|
||||
After(machine.LastSeen.Add(h.cfg.EphemeralNodeInactivityTimeout)) {
|
||||
log.Info().
|
||||
Str("machine", machine.Name).
|
||||
Msg("Ephemeral client removed from database")
|
||||
|
||||
err = h.db.Unscoped().Delete(machine).Error
|
||||
if err != nil {
|
||||
log.Printf("[%s] 🤮 Cannot delete ephemeral machine from the database: %s", m.Name, err)
|
||||
log.Error().
|
||||
Err(err).
|
||||
Str("machine", machine.Name).
|
||||
Msg("🤮 Cannot delete ephemeral machine from the database")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
h.setLastStateChangeToNow(namespace.Name)
|
||||
}
|
||||
}
|
||||
|
||||
// Serve launches a GIN server with the Headscale API
|
||||
func (h *Headscale) grpcAuthenticationInterceptor(ctx context.Context,
|
||||
req interface{},
|
||||
info *grpc.UnaryServerInfo,
|
||||
handler grpc.UnaryHandler) (interface{}, error) {
|
||||
// Check if the request is coming from the on-server client.
|
||||
// This is not secure, but it is to maintain maintainability
|
||||
// with the "legacy" database-based client
|
||||
// It is also neede for grpc-gateway to be able to connect to
|
||||
// the server
|
||||
client, _ := peer.FromContext(ctx)
|
||||
|
||||
log.Trace().
|
||||
Caller().
|
||||
Str("client_address", client.Addr.String()).
|
||||
Msg("Client is trying to authenticate")
|
||||
|
||||
meta, ok := metadata.FromIncomingContext(ctx)
|
||||
if !ok {
|
||||
log.Error().
|
||||
Caller().
|
||||
Str("client_address", client.Addr.String()).
|
||||
Msg("Retrieving metadata is failed")
|
||||
|
||||
return ctx, status.Errorf(
|
||||
codes.InvalidArgument,
|
||||
"Retrieving metadata is failed",
|
||||
)
|
||||
}
|
||||
|
||||
authHeader, ok := meta["authorization"]
|
||||
if !ok {
|
||||
log.Error().
|
||||
Caller().
|
||||
Str("client_address", client.Addr.String()).
|
||||
Msg("Authorization token is not supplied")
|
||||
|
||||
return ctx, status.Errorf(
|
||||
codes.Unauthenticated,
|
||||
"Authorization token is not supplied",
|
||||
)
|
||||
}
|
||||
|
||||
token := authHeader[0]
|
||||
|
||||
if !strings.HasPrefix(token, AuthPrefix) {
|
||||
log.Error().
|
||||
Caller().
|
||||
Str("client_address", client.Addr.String()).
|
||||
Msg(`missing "Bearer " prefix in "Authorization" header`)
|
||||
|
||||
return ctx, status.Error(
|
||||
codes.Unauthenticated,
|
||||
`missing "Bearer " prefix in "Authorization" header`,
|
||||
)
|
||||
}
|
||||
|
||||
valid, err := h.ValidateAPIKey(strings.TrimPrefix(token, AuthPrefix))
|
||||
if err != nil {
|
||||
log.Error().
|
||||
Caller().
|
||||
Err(err).
|
||||
Str("client_address", client.Addr.String()).
|
||||
Msg("failed to validate token")
|
||||
|
||||
return ctx, status.Error(codes.Internal, "failed to validate token")
|
||||
}
|
||||
|
||||
if !valid {
|
||||
log.Info().
|
||||
Str("client_address", client.Addr.String()).
|
||||
Msg("invalid token")
|
||||
|
||||
return ctx, status.Error(codes.Unauthenticated, "invalid token")
|
||||
}
|
||||
|
||||
return handler(ctx, req)
|
||||
}
|
||||
|
||||
func (h *Headscale) httpAuthenticationMiddleware(ctx *gin.Context) {
|
||||
log.Trace().
|
||||
Caller().
|
||||
Str("client_address", ctx.ClientIP()).
|
||||
Msg("HTTP authentication invoked")
|
||||
|
||||
authHeader := ctx.GetHeader("authorization")
|
||||
|
||||
if !strings.HasPrefix(authHeader, AuthPrefix) {
|
||||
log.Error().
|
||||
Caller().
|
||||
Str("client_address", ctx.ClientIP()).
|
||||
Msg(`missing "Bearer " prefix in "Authorization" header`)
|
||||
ctx.AbortWithStatus(http.StatusUnauthorized)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
valid, err := h.ValidateAPIKey(strings.TrimPrefix(authHeader, AuthPrefix))
|
||||
if err != nil {
|
||||
log.Error().
|
||||
Caller().
|
||||
Err(err).
|
||||
Str("client_address", ctx.ClientIP()).
|
||||
Msg("failed to validate token")
|
||||
|
||||
ctx.AbortWithStatus(http.StatusInternalServerError)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if !valid {
|
||||
log.Info().
|
||||
Str("client_address", ctx.ClientIP()).
|
||||
Msg("invalid token")
|
||||
|
||||
ctx.AbortWithStatus(http.StatusUnauthorized)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Next()
|
||||
}
|
||||
|
||||
// ensureUnixSocketIsAbsent will check if the given path for headscales unix socket is clear
|
||||
// and will remove it if it is not.
|
||||
func (h *Headscale) ensureUnixSocketIsAbsent() error {
|
||||
// File does not exist, all fine
|
||||
if _, err := os.Stat(h.cfg.UnixSocket); errors.Is(err, os.ErrNotExist) {
|
||||
return nil
|
||||
}
|
||||
|
||||
return os.Remove(h.cfg.UnixSocket)
|
||||
}
|
||||
|
||||
func (h *Headscale) createPrometheusRouter() *gin.Engine {
|
||||
promRouter := gin.Default()
|
||||
|
||||
prometheus := ginprometheus.NewPrometheus("gin")
|
||||
prometheus.Use(promRouter)
|
||||
|
||||
return promRouter
|
||||
}
|
||||
|
||||
func (h *Headscale) createRouter(grpcMux *runtime.ServeMux) *gin.Engine {
|
||||
router := gin.Default()
|
||||
|
||||
router.GET(
|
||||
"/health",
|
||||
func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"healthy": "ok"}) },
|
||||
)
|
||||
router.GET("/key", h.KeyHandler)
|
||||
router.GET("/register", h.RegisterWebAPI)
|
||||
router.POST("/machine/:id/map", h.PollNetMapHandler)
|
||||
router.POST("/machine/:id", h.RegistrationHandler)
|
||||
router.GET("/oidc/register/:mkey", h.RegisterOIDC)
|
||||
router.GET("/oidc/callback", h.OIDCCallback)
|
||||
router.GET("/apple", h.AppleConfigMessage)
|
||||
router.GET("/apple/:platform", h.ApplePlatformConfig)
|
||||
router.GET("/windows", h.WindowsConfigMessage)
|
||||
router.GET("/windows/tailscale.reg", h.WindowsRegConfig)
|
||||
router.GET("/swagger", SwaggerUI)
|
||||
router.GET("/swagger/v1/openapiv2.json", SwaggerAPIv1)
|
||||
|
||||
if h.cfg.DERP.ServerEnabled {
|
||||
router.Any("/derp", h.DERPHandler)
|
||||
router.Any("/derp/probe", h.DERPProbeHandler)
|
||||
router.Any("/bootstrap-dns", h.DERPBootstrapDNSHandler)
|
||||
}
|
||||
|
||||
api := router.Group("/api")
|
||||
api.Use(h.httpAuthenticationMiddleware)
|
||||
{
|
||||
api.Any("/v1/*any", gin.WrapF(grpcMux.ServeHTTP))
|
||||
}
|
||||
|
||||
router.NoRoute(stdoutHandler)
|
||||
|
||||
return router
|
||||
}
|
||||
|
||||
// Serve launches a GIN server with the Headscale API.
|
||||
func (h *Headscale) Serve() error {
|
||||
r := gin.Default()
|
||||
r.GET("/key", h.KeyHandler)
|
||||
r.GET("/register", h.RegisterWebAPI)
|
||||
r.POST("/machine/:id/map", h.PollNetMapHandler)
|
||||
r.POST("/machine/:id", h.RegistrationHandler)
|
||||
var err error
|
||||
|
||||
// Fetch an initial DERP Map before we start serving
|
||||
h.DERPMap = GetDERPMap(h.cfg.DERP)
|
||||
|
||||
if h.cfg.DERP.ServerEnabled {
|
||||
// When embedded DERP is enabled we always need a STUN server
|
||||
if h.cfg.DERP.STUNAddr == "" {
|
||||
return errSTUNAddressNotSet
|
||||
}
|
||||
|
||||
h.DERPMap.Regions[h.DERPServer.region.RegionID] = &h.DERPServer.region
|
||||
go h.ServeSTUN()
|
||||
}
|
||||
|
||||
if h.cfg.DERP.AutoUpdate {
|
||||
derpMapCancelChannel := make(chan struct{})
|
||||
defer func() { derpMapCancelChannel <- struct{}{} }()
|
||||
go h.scheduledDERPMapUpdateWorker(derpMapCancelChannel)
|
||||
}
|
||||
|
||||
go h.expireEphemeralNodes(updateInterval)
|
||||
|
||||
if zl.GlobalLevel() == zl.TraceLevel {
|
||||
zerolog.RespLog = true
|
||||
} else {
|
||||
zerolog.RespLog = false
|
||||
}
|
||||
|
||||
// Prepare group for running listeners
|
||||
errorGroup := new(errgroup.Group)
|
||||
|
||||
ctx := context.Background()
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
|
||||
//
|
||||
//
|
||||
// Set up LOCAL listeners
|
||||
//
|
||||
|
||||
err = h.ensureUnixSocketIsAbsent()
|
||||
if err != nil {
|
||||
return fmt.Errorf("unable to remove old socket file: %w", err)
|
||||
}
|
||||
|
||||
socketListener, err := net.Listen("unix", h.cfg.UnixSocket)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to set up gRPC socket: %w", err)
|
||||
}
|
||||
|
||||
// Change socket permissions
|
||||
if err := os.Chmod(h.cfg.UnixSocket, h.cfg.UnixSocketPermission); err != nil {
|
||||
return fmt.Errorf("failed change permission of gRPC socket: %w", err)
|
||||
}
|
||||
|
||||
// Handle common process-killing signals so we can gracefully shut down:
|
||||
sigc := make(chan os.Signal, 1)
|
||||
signal.Notify(sigc, os.Interrupt, syscall.SIGTERM)
|
||||
go func(c chan os.Signal) {
|
||||
// Wait for a SIGINT or SIGKILL:
|
||||
sig := <-c
|
||||
log.Printf("Caught signal %s: shutting down.", sig)
|
||||
// Stop listening (and unlink the socket if unix type):
|
||||
socketListener.Close()
|
||||
// And we're done:
|
||||
os.Exit(0)
|
||||
}(sigc)
|
||||
|
||||
grpcGatewayMux := runtime.NewServeMux()
|
||||
|
||||
// Make the grpc-gateway connect to grpc over socket
|
||||
grpcGatewayConn, err := grpc.Dial(
|
||||
h.cfg.UnixSocket,
|
||||
[]grpc.DialOption{
|
||||
grpc.WithTransportCredentials(insecure.NewCredentials()),
|
||||
grpc.WithContextDialer(GrpcSocketDialer),
|
||||
}...,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Connect to the gRPC server over localhost to skip
|
||||
// the authentication.
|
||||
err = v1.RegisterHeadscaleServiceHandler(ctx, grpcGatewayMux, grpcGatewayConn)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Start the local gRPC server without TLS and without authentication
|
||||
grpcSocket := grpc.NewServer(zerolog.UnaryInterceptor())
|
||||
|
||||
v1.RegisterHeadscaleServiceServer(grpcSocket, newHeadscaleV1APIServer(h))
|
||||
reflection.Register(grpcSocket)
|
||||
|
||||
errorGroup.Go(func() error { return grpcSocket.Serve(socketListener) })
|
||||
|
||||
//
|
||||
//
|
||||
// Set up REMOTE listeners
|
||||
//
|
||||
|
||||
tlsConfig, err := h.getTLSSettings()
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("Failed to set up TLS configuration")
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
//
|
||||
//
|
||||
// gRPC setup
|
||||
//
|
||||
|
||||
// We are sadly not able to run gRPC and HTTPS (2.0) on the same
|
||||
// port because the connection mux does not support matching them
|
||||
// since they are so similar. There is multiple issues open and we
|
||||
// can revisit this if changes:
|
||||
// https://github.com/soheilhy/cmux/issues/68
|
||||
// https://github.com/soheilhy/cmux/issues/91
|
||||
|
||||
if tlsConfig != nil || h.cfg.GRPCAllowInsecure {
|
||||
log.Info().Msgf("Enabling remote gRPC at %s", h.cfg.GRPCAddr)
|
||||
|
||||
grpcOptions := []grpc.ServerOption{
|
||||
grpc.UnaryInterceptor(
|
||||
grpc_middleware.ChainUnaryServer(
|
||||
h.grpcAuthenticationInterceptor,
|
||||
zerolog.NewUnaryServerInterceptor(),
|
||||
),
|
||||
),
|
||||
}
|
||||
|
||||
if tlsConfig != nil {
|
||||
grpcOptions = append(grpcOptions,
|
||||
grpc.Creds(credentials.NewTLS(tlsConfig)),
|
||||
)
|
||||
} else {
|
||||
log.Warn().Msg("gRPC is running without security")
|
||||
}
|
||||
|
||||
grpcServer := grpc.NewServer(grpcOptions...)
|
||||
|
||||
v1.RegisterHeadscaleServiceServer(grpcServer, newHeadscaleV1APIServer(h))
|
||||
reflection.Register(grpcServer)
|
||||
|
||||
grpcListener, err := net.Listen("tcp", h.cfg.GRPCAddr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to bind to TCP address: %w", err)
|
||||
}
|
||||
|
||||
errorGroup.Go(func() error { return grpcServer.Serve(grpcListener) })
|
||||
|
||||
log.Info().
|
||||
Msgf("listening and serving gRPC on: %s", h.cfg.GRPCAddr)
|
||||
}
|
||||
|
||||
//
|
||||
//
|
||||
// HTTP setup
|
||||
//
|
||||
|
||||
router := h.createRouter(grpcGatewayMux)
|
||||
|
||||
httpServer := &http.Server{
|
||||
Addr: h.cfg.Addr,
|
||||
Handler: router,
|
||||
ReadTimeout: HTTPReadTimeout,
|
||||
// Go does not handle timeouts in HTTP very well, and there is
|
||||
// no good way to handle streaming timeouts, therefore we need to
|
||||
// keep this at unlimited and be careful to clean up connections
|
||||
// https://blog.cloudflare.com/the-complete-guide-to-golang-net-http-timeouts/#aboutstreaming
|
||||
WriteTimeout: 0,
|
||||
}
|
||||
|
||||
var httpListener net.Listener
|
||||
if tlsConfig != nil {
|
||||
httpServer.TLSConfig = tlsConfig
|
||||
httpListener, err = tls.Listen("tcp", h.cfg.Addr, tlsConfig)
|
||||
} else {
|
||||
httpListener, err = net.Listen("tcp", h.cfg.Addr)
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to bind to TCP address: %w", err)
|
||||
}
|
||||
|
||||
errorGroup.Go(func() error { return httpServer.Serve(httpListener) })
|
||||
|
||||
log.Info().
|
||||
Msgf("listening and serving HTTP on: %s", h.cfg.Addr)
|
||||
|
||||
promRouter := h.createPrometheusRouter()
|
||||
|
||||
promHTTPServer := &http.Server{
|
||||
Addr: h.cfg.MetricsAddr,
|
||||
Handler: promRouter,
|
||||
ReadTimeout: HTTPReadTimeout,
|
||||
WriteTimeout: 0,
|
||||
}
|
||||
|
||||
var promHTTPListener net.Listener
|
||||
promHTTPListener, err = net.Listen("tcp", h.cfg.MetricsAddr)
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to bind to TCP address: %w", err)
|
||||
}
|
||||
|
||||
errorGroup.Go(func() error { return promHTTPServer.Serve(promHTTPListener) })
|
||||
|
||||
log.Info().
|
||||
Msgf("listening and serving metrics on: %s", h.cfg.MetricsAddr)
|
||||
|
||||
return errorGroup.Wait()
|
||||
}
|
||||
|
||||
func (h *Headscale) getTLSSettings() (*tls.Config, error) {
|
||||
var err error
|
||||
if h.cfg.TLSLetsEncryptHostname != "" {
|
||||
if !strings.HasPrefix(h.cfg.ServerURL, "https://") {
|
||||
log.Println("WARNING: listening with TLS but ServerURL does not start with https://")
|
||||
log.Warn().
|
||||
Msg("Listening with TLS but ServerURL does not start with https://")
|
||||
}
|
||||
|
||||
m := autocert.Manager{
|
||||
certManager := autocert.Manager{
|
||||
Prompt: autocert.AcceptTOS,
|
||||
HostPolicy: autocert.HostWhitelist(h.cfg.TLSLetsEncryptHostname),
|
||||
Cache: autocert.DirCache(h.cfg.TLSLetsEncryptCacheDir),
|
||||
Client: &acme.Client{
|
||||
DirectoryURL: h.cfg.ACMEURL,
|
||||
},
|
||||
Email: h.cfg.ACMEEmail,
|
||||
}
|
||||
s := &http.Server{
|
||||
Addr: h.cfg.Addr,
|
||||
TLSConfig: m.TLSConfig(),
|
||||
Handler: r,
|
||||
}
|
||||
if h.cfg.TLSLetsEncryptChallengeType == "TLS-ALPN-01" {
|
||||
|
||||
switch h.cfg.TLSLetsEncryptChallengeType {
|
||||
case "TLS-ALPN-01":
|
||||
// Configuration via autocert with TLS-ALPN-01 (https://tools.ietf.org/html/rfc8737)
|
||||
// The RFC requires that the validation is done on port 443; in other words, headscale
|
||||
// must be configured to run on port 443.
|
||||
err = s.ListenAndServeTLS("", "")
|
||||
} else if h.cfg.TLSLetsEncryptChallengeType == "HTTP-01" {
|
||||
// must be reachable on port 443.
|
||||
return certManager.TLSConfig(), nil
|
||||
|
||||
case "HTTP-01":
|
||||
// Configuration via autocert with HTTP-01. This requires listening on
|
||||
// port 80 for the certificate validation in addition to the headscale
|
||||
// service, which can be configured to run on any other port.
|
||||
go func() {
|
||||
log.Fatal(http.ListenAndServe(":http", m.HTTPHandler(http.HandlerFunc(h.redirect))))
|
||||
log.Fatal().
|
||||
Caller().
|
||||
Err(http.ListenAndServe(h.cfg.TLSLetsEncryptListen, certManager.HTTPHandler(http.HandlerFunc(h.redirect)))).
|
||||
Msg("failed to set up a HTTP server")
|
||||
}()
|
||||
err = s.ListenAndServeTLS("", "")
|
||||
} else {
|
||||
return errors.New("Unknown value for TLSLetsEncryptChallengeType")
|
||||
|
||||
return certManager.TLSConfig(), nil
|
||||
|
||||
default:
|
||||
return nil, errUnsupportedLetsEncryptChallengeType
|
||||
}
|
||||
} else if h.cfg.TLSCertPath == "" {
|
||||
if !strings.HasPrefix(h.cfg.ServerURL, "http://") {
|
||||
log.Println("WARNING: listening without TLS but ServerURL does not start with http://")
|
||||
log.Warn().Msg("Listening without TLS but ServerURL does not start with http://")
|
||||
}
|
||||
err = r.Run(h.cfg.Addr)
|
||||
|
||||
return nil, err
|
||||
} else {
|
||||
if !strings.HasPrefix(h.cfg.ServerURL, "https://") {
|
||||
log.Println("WARNING: listening with TLS but ServerURL does not start with https://")
|
||||
log.Warn().Msg("Listening with TLS but ServerURL does not start with https://")
|
||||
}
|
||||
err = r.RunTLS(h.cfg.Addr, h.cfg.TLSCertPath, h.cfg.TLSKeyPath)
|
||||
|
||||
log.Info().Msg(fmt.Sprintf(
|
||||
"Client authentication (mTLS) is \"%s\". See the docs to learn about configuring this setting.",
|
||||
h.cfg.TLSClientAuthMode))
|
||||
|
||||
tlsConfig := &tls.Config{
|
||||
ClientAuth: h.cfg.TLSClientAuthMode,
|
||||
NextProtos: []string{"http/1.1"},
|
||||
Certificates: make([]tls.Certificate, 1),
|
||||
MinVersion: tls.VersionTLS12,
|
||||
}
|
||||
|
||||
tlsConfig.Certificates[0], err = tls.LoadX509KeyPair(h.cfg.TLSCertPath, h.cfg.TLSKeyPath)
|
||||
|
||||
return tlsConfig, err
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (h *Headscale) setLastStateChangeToNow(namespace string) {
|
||||
now := time.Now().UTC()
|
||||
lastStateUpdate.WithLabelValues("", "headscale").Set(float64(now.Unix()))
|
||||
h.lastStateChange.Store(namespace, now)
|
||||
}
|
||||
|
||||
func (h *Headscale) getLastStateChange(namespaces ...string) time.Time {
|
||||
times := []time.Time{}
|
||||
|
||||
for _, namespace := range namespaces {
|
||||
if wrapped, ok := h.lastStateChange.Load(namespace); ok {
|
||||
lastChange, _ := wrapped.(time.Time)
|
||||
|
||||
times = append(times, lastChange)
|
||||
}
|
||||
}
|
||||
|
||||
sort.Slice(times, func(i, j int) bool {
|
||||
return times[i].After(times[j])
|
||||
})
|
||||
|
||||
log.Trace().Msgf("Latest times %#v", times)
|
||||
|
||||
if len(times) == 0 {
|
||||
return time.Now().UTC()
|
||||
} else {
|
||||
return times[0]
|
||||
}
|
||||
}
|
||||
|
||||
func stdoutHandler(ctx *gin.Context) {
|
||||
body, _ := io.ReadAll(ctx.Request.Body)
|
||||
|
||||
log.Trace().
|
||||
Interface("header", ctx.Request.Header).
|
||||
Interface("proto", ctx.Request.Proto).
|
||||
Interface("url", ctx.Request.URL).
|
||||
Bytes("body", body).
|
||||
Msg("Request did not match")
|
||||
}
|
||||
|
||||
func readOrCreatePrivateKey(path string) (*key.MachinePrivate, error) {
|
||||
privateKey, err := os.ReadFile(path)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
log.Info().Str("path", path).Msg("No private key file at path, creating...")
|
||||
|
||||
machineKey := key.NewMachine()
|
||||
|
||||
machineKeyStr, err := machineKey.MarshalText()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf(
|
||||
"failed to convert private key to string for saving: %w",
|
||||
err,
|
||||
)
|
||||
}
|
||||
err = os.WriteFile(path, machineKeyStr, privateKeyFileMode)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf(
|
||||
"failed to save private key to disk: %w",
|
||||
err,
|
||||
)
|
||||
}
|
||||
|
||||
return &machineKey, nil
|
||||
} else if err != nil {
|
||||
return nil, fmt.Errorf("failed to read private key file: %w", err)
|
||||
}
|
||||
|
||||
trimmedPrivateKey := strings.TrimSpace(string(privateKey))
|
||||
privateKeyEnsurePrefix := PrivateKeyEnsurePrefix(trimmedPrivateKey)
|
||||
|
||||
var machineKey key.MachinePrivate
|
||||
if err = machineKey.UnmarshalText([]byte(privateKeyEnsurePrefix)); err != nil {
|
||||
log.Info().
|
||||
Str("path", path).
|
||||
Msg("This might be due to a legacy (headscale pre-0.12) private key. " +
|
||||
"If the key is in WireGuard format, delete the key and restart headscale. " +
|
||||
"A new key will automatically be generated. All Tailscale clients will have to be restarted")
|
||||
|
||||
return nil, fmt.Errorf("failed to parse private key: %w", err)
|
||||
}
|
||||
|
||||
return &machineKey, nil
|
||||
}
|
||||
|
||||
38
app_test.go
38
app_test.go
@@ -6,6 +6,7 @@ import (
|
||||
"testing"
|
||||
|
||||
"gopkg.in/check.v1"
|
||||
"inet.af/netaddr"
|
||||
)
|
||||
|
||||
func Test(t *testing.T) {
|
||||
@@ -16,8 +17,10 @@ var _ = check.Suite(&Suite{})
|
||||
|
||||
type Suite struct{}
|
||||
|
||||
var tmpDir string
|
||||
var h Headscale
|
||||
var (
|
||||
tmpDir string
|
||||
app Headscale
|
||||
)
|
||||
|
||||
func (s *Suite) SetUpTest(c *check.C) {
|
||||
s.ResetDB(c)
|
||||
@@ -36,20 +39,41 @@ func (s *Suite) ResetDB(c *check.C) {
|
||||
if err != nil {
|
||||
c.Fatal(err)
|
||||
}
|
||||
cfg := Config{}
|
||||
cfg := Config{
|
||||
IPPrefixes: []netaddr.IPPrefix{
|
||||
netaddr.MustParseIPPrefix("10.27.0.0/23"),
|
||||
},
|
||||
}
|
||||
|
||||
h = Headscale{
|
||||
app = Headscale{
|
||||
cfg: cfg,
|
||||
dbType: "sqlite3",
|
||||
dbString: tmpDir + "/headscale_test.db",
|
||||
}
|
||||
err = h.initDB()
|
||||
err = app.initDB()
|
||||
if err != nil {
|
||||
c.Fatal(err)
|
||||
}
|
||||
db, err := h.openDB()
|
||||
db, err := app.openDB()
|
||||
if err != nil {
|
||||
c.Fatal(err)
|
||||
}
|
||||
h.db = db
|
||||
app.db = db
|
||||
}
|
||||
|
||||
// Enusre an error is returned when an invalid auth mode
|
||||
// is supplied.
|
||||
func (s *Suite) TestInvalidClientAuthMode(c *check.C) {
|
||||
_, isValid := LookupTLSClientAuthMode("invalid")
|
||||
c.Assert(isValid, check.Equals, false)
|
||||
}
|
||||
|
||||
// Ensure that all client auth modes return a nil error.
|
||||
func (s *Suite) TestAuthModes(c *check.C) {
|
||||
modes := []string{"disabled", "relaxed", "enforced"}
|
||||
|
||||
for _, v := range modes {
|
||||
_, isValid := LookupTLSClientAuthMode(v)
|
||||
c.Assert(isValid, check.Equals, true)
|
||||
}
|
||||
}
|
||||
|
||||
21
buf.gen.yaml
Normal file
21
buf.gen.yaml
Normal file
@@ -0,0 +1,21 @@
|
||||
version: v1
|
||||
plugins:
|
||||
- name: go
|
||||
out: gen/go
|
||||
opt:
|
||||
- paths=source_relative
|
||||
- name: go-grpc
|
||||
out: gen/go
|
||||
opt:
|
||||
- paths=source_relative
|
||||
- name: grpc-gateway
|
||||
out: gen/go
|
||||
opt:
|
||||
- paths=source_relative
|
||||
- generate_unbound_methods=true
|
||||
# - name: gorm
|
||||
# out: gen/go
|
||||
# opt:
|
||||
# - paths=source_relative,enums=string,gateway=true
|
||||
- name: openapiv2
|
||||
out: gen/openapiv2
|
||||
40
cli.go
40
cli.go
@@ -1,40 +0,0 @@
|
||||
package headscale
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"tailscale.com/types/wgkey"
|
||||
)
|
||||
|
||||
// RegisterMachine is executed from the CLI to register a new Machine using its MachineKey
|
||||
func (h *Headscale) RegisterMachine(key string, namespace string) (*Machine, error) {
|
||||
ns, err := h.GetNamespace(namespace)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
mKey, err := wgkey.ParseHex(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
m := Machine{}
|
||||
if result := h.db.First(&m, "machine_key = ?", mKey.HexString()); errors.Is(result.Error, gorm.ErrRecordNotFound) {
|
||||
return nil, errors.New("Machine not found")
|
||||
}
|
||||
|
||||
if m.isAlreadyRegistered() {
|
||||
return nil, errors.New("Machine already registered")
|
||||
}
|
||||
|
||||
ip, err := h.getAvailableIP()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
m.IPAddress = ip.String()
|
||||
m.NamespaceID = ns.ID
|
||||
m.Registered = true
|
||||
m.RegisterMethod = "cli"
|
||||
h.db.Save(&m)
|
||||
return &m, nil
|
||||
}
|
||||
30
cli_test.go
30
cli_test.go
@@ -1,30 +0,0 @@
|
||||
package headscale
|
||||
|
||||
import (
|
||||
"gopkg.in/check.v1"
|
||||
)
|
||||
|
||||
func (s *Suite) TestRegisterMachine(c *check.C) {
|
||||
n, err := h.CreateNamespace("test")
|
||||
c.Assert(err, check.IsNil)
|
||||
|
||||
m := Machine{
|
||||
ID: 0,
|
||||
MachineKey: "8ce002a935f8c394e55e78fbbb410576575ff8ec5cfa2e627e4b807f1be15b0e",
|
||||
NodeKey: "bar",
|
||||
DiscoKey: "faa",
|
||||
Name: "testmachine",
|
||||
NamespaceID: n.ID,
|
||||
}
|
||||
h.db.Save(&m)
|
||||
|
||||
_, err = h.GetMachine("test", "testmachine")
|
||||
c.Assert(err, check.IsNil)
|
||||
|
||||
m2, err := h.RegisterMachine("8ce002a935f8c394e55e78fbbb410576575ff8ec5cfa2e627e4b807f1be15b0e", n.Name)
|
||||
c.Assert(err, check.IsNil)
|
||||
c.Assert(m2.Registered, check.Equals, true)
|
||||
|
||||
_, err = m2.GetHostInfo()
|
||||
c.Assert(err, check.IsNil)
|
||||
}
|
||||
186
cmd/headscale/cli/api_key.go
Normal file
186
cmd/headscale/cli/api_key.go
Normal file
@@ -0,0 +1,186 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/juanfont/headscale"
|
||||
v1 "github.com/juanfont/headscale/gen/go/headscale/v1"
|
||||
"github.com/pterm/pterm"
|
||||
"github.com/rs/zerolog/log"
|
||||
"github.com/spf13/cobra"
|
||||
"google.golang.org/protobuf/types/known/timestamppb"
|
||||
)
|
||||
|
||||
const (
|
||||
// 90 days.
|
||||
DefaultAPIKeyExpiry = 90 * 24 * time.Hour
|
||||
)
|
||||
|
||||
func init() {
|
||||
rootCmd.AddCommand(apiKeysCmd)
|
||||
apiKeysCmd.AddCommand(listAPIKeys)
|
||||
|
||||
createAPIKeyCmd.Flags().
|
||||
DurationP("expiration", "e", DefaultAPIKeyExpiry, "Human-readable expiration of the key (30m, 24h, 365d...)")
|
||||
|
||||
apiKeysCmd.AddCommand(createAPIKeyCmd)
|
||||
|
||||
expireAPIKeyCmd.Flags().StringP("prefix", "p", "", "ApiKey prefix")
|
||||
err := expireAPIKeyCmd.MarkFlagRequired("prefix")
|
||||
if err != nil {
|
||||
log.Fatal().Err(err).Msg("")
|
||||
}
|
||||
apiKeysCmd.AddCommand(expireAPIKeyCmd)
|
||||
}
|
||||
|
||||
var apiKeysCmd = &cobra.Command{
|
||||
Use: "apikeys",
|
||||
Short: "Handle the Api keys in Headscale",
|
||||
Aliases: []string{"apikey", "api"},
|
||||
}
|
||||
|
||||
var listAPIKeys = &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List the Api keys for headscale",
|
||||
Aliases: []string{"ls", "show"},
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
output, _ := cmd.Flags().GetString("output")
|
||||
|
||||
ctx, client, conn, cancel := getHeadscaleCLIClient()
|
||||
defer cancel()
|
||||
defer conn.Close()
|
||||
|
||||
request := &v1.ListApiKeysRequest{}
|
||||
|
||||
response, err := client.ListApiKeys(ctx, request)
|
||||
if err != nil {
|
||||
ErrorOutput(
|
||||
err,
|
||||
fmt.Sprintf("Error getting the list of keys: %s", err),
|
||||
output,
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if output != "" {
|
||||
SuccessOutput(response.ApiKeys, "", output)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
tableData := pterm.TableData{
|
||||
{"ID", "Prefix", "Expiration", "Created"},
|
||||
}
|
||||
for _, key := range response.ApiKeys {
|
||||
expiration := "-"
|
||||
|
||||
if key.GetExpiration() != nil {
|
||||
expiration = ColourTime(key.Expiration.AsTime())
|
||||
}
|
||||
|
||||
tableData = append(tableData, []string{
|
||||
strconv.FormatUint(key.GetId(), headscale.Base10),
|
||||
key.GetPrefix(),
|
||||
expiration,
|
||||
key.GetCreatedAt().AsTime().Format(HeadscaleDateTimeFormat),
|
||||
})
|
||||
|
||||
}
|
||||
err = pterm.DefaultTable.WithHasHeader().WithData(tableData).Render()
|
||||
if err != nil {
|
||||
ErrorOutput(
|
||||
err,
|
||||
fmt.Sprintf("Failed to render pterm table: %s", err),
|
||||
output,
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
var createAPIKeyCmd = &cobra.Command{
|
||||
Use: "create",
|
||||
Short: "Creates a new Api key",
|
||||
Long: `
|
||||
Creates a new Api key, the Api key is only visible on creation
|
||||
and cannot be retrieved again.
|
||||
If you loose a key, create a new one and revoke (expire) the old one.`,
|
||||
Aliases: []string{"c", "new"},
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
output, _ := cmd.Flags().GetString("output")
|
||||
|
||||
log.Trace().
|
||||
Msg("Preparing to create ApiKey")
|
||||
|
||||
request := &v1.CreateApiKeyRequest{}
|
||||
|
||||
duration, _ := cmd.Flags().GetDuration("expiration")
|
||||
expiration := time.Now().UTC().Add(duration)
|
||||
|
||||
log.Trace().Dur("expiration", duration).Msg("expiration has been set")
|
||||
|
||||
request.Expiration = timestamppb.New(expiration)
|
||||
|
||||
ctx, client, conn, cancel := getHeadscaleCLIClient()
|
||||
defer cancel()
|
||||
defer conn.Close()
|
||||
|
||||
response, err := client.CreateApiKey(ctx, request)
|
||||
if err != nil {
|
||||
ErrorOutput(
|
||||
err,
|
||||
fmt.Sprintf("Cannot create Api Key: %s\n", err),
|
||||
output,
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
SuccessOutput(response.ApiKey, response.ApiKey, output)
|
||||
},
|
||||
}
|
||||
|
||||
var expireAPIKeyCmd = &cobra.Command{
|
||||
Use: "expire",
|
||||
Short: "Expire an ApiKey",
|
||||
Aliases: []string{"revoke", "exp", "e"},
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
output, _ := cmd.Flags().GetString("output")
|
||||
|
||||
prefix, err := cmd.Flags().GetString("prefix")
|
||||
if err != nil {
|
||||
ErrorOutput(
|
||||
err,
|
||||
fmt.Sprintf("Error getting prefix from CLI flag: %s", err),
|
||||
output,
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
ctx, client, conn, cancel := getHeadscaleCLIClient()
|
||||
defer cancel()
|
||||
defer conn.Close()
|
||||
|
||||
request := &v1.ExpireApiKeyRequest{
|
||||
Prefix: prefix,
|
||||
}
|
||||
|
||||
response, err := client.ExpireApiKey(ctx, request)
|
||||
if err != nil {
|
||||
ErrorOutput(
|
||||
err,
|
||||
fmt.Sprintf("Cannot expire Api Key: %s\n", err),
|
||||
output,
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
SuccessOutput(response, "Key expired", output)
|
||||
},
|
||||
}
|
||||
132
cmd/headscale/cli/debug.go
Normal file
132
cmd/headscale/cli/debug.go
Normal file
@@ -0,0 +1,132 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
v1 "github.com/juanfont/headscale/gen/go/headscale/v1"
|
||||
"github.com/rs/zerolog/log"
|
||||
"github.com/spf13/cobra"
|
||||
"google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
const (
|
||||
keyLength = 64
|
||||
errPreAuthKeyTooShort = Error("key too short, must be 64 hexadecimal characters")
|
||||
)
|
||||
|
||||
// Error is used to compare errors as per https://dave.cheney.net/2016/04/07/constant-errors
|
||||
type Error string
|
||||
|
||||
func (e Error) Error() string { return string(e) }
|
||||
|
||||
func init() {
|
||||
rootCmd.AddCommand(debugCmd)
|
||||
|
||||
createNodeCmd.Flags().StringP("name", "", "", "Name")
|
||||
err := createNodeCmd.MarkFlagRequired("name")
|
||||
if err != nil {
|
||||
log.Fatal().Err(err).Msg("")
|
||||
}
|
||||
createNodeCmd.Flags().StringP("namespace", "n", "", "Namespace")
|
||||
err = createNodeCmd.MarkFlagRequired("namespace")
|
||||
if err != nil {
|
||||
log.Fatal().Err(err).Msg("")
|
||||
}
|
||||
createNodeCmd.Flags().StringP("key", "k", "", "Key")
|
||||
err = createNodeCmd.MarkFlagRequired("key")
|
||||
if err != nil {
|
||||
log.Fatal().Err(err).Msg("")
|
||||
}
|
||||
createNodeCmd.Flags().
|
||||
StringSliceP("route", "r", []string{}, "List (or repeated flags) of routes to advertise")
|
||||
|
||||
debugCmd.AddCommand(createNodeCmd)
|
||||
}
|
||||
|
||||
var debugCmd = &cobra.Command{
|
||||
Use: "debug",
|
||||
Short: "debug and testing commands",
|
||||
Long: "debug contains extra commands used for debugging and testing headscale",
|
||||
}
|
||||
|
||||
var createNodeCmd = &cobra.Command{
|
||||
Use: "create-node",
|
||||
Short: "Create a node (machine) that can be registered with `nodes register <>` command",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
output, _ := cmd.Flags().GetString("output")
|
||||
|
||||
namespace, err := cmd.Flags().GetString("namespace")
|
||||
if err != nil {
|
||||
ErrorOutput(err, fmt.Sprintf("Error getting namespace: %s", err), output)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
ctx, client, conn, cancel := getHeadscaleCLIClient()
|
||||
defer cancel()
|
||||
defer conn.Close()
|
||||
|
||||
name, err := cmd.Flags().GetString("name")
|
||||
if err != nil {
|
||||
ErrorOutput(
|
||||
err,
|
||||
fmt.Sprintf("Error getting node from flag: %s", err),
|
||||
output,
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
machineKey, err := cmd.Flags().GetString("key")
|
||||
if err != nil {
|
||||
ErrorOutput(
|
||||
err,
|
||||
fmt.Sprintf("Error getting key from flag: %s", err),
|
||||
output,
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
if len(machineKey) != keyLength {
|
||||
err = errPreAuthKeyTooShort
|
||||
ErrorOutput(
|
||||
err,
|
||||
fmt.Sprintf("Error: %s", err),
|
||||
output,
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
routes, err := cmd.Flags().GetStringSlice("route")
|
||||
if err != nil {
|
||||
ErrorOutput(
|
||||
err,
|
||||
fmt.Sprintf("Error getting routes from flag: %s", err),
|
||||
output,
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
request := &v1.DebugCreateMachineRequest{
|
||||
Key: machineKey,
|
||||
Name: name,
|
||||
Namespace: namespace,
|
||||
Routes: routes,
|
||||
}
|
||||
|
||||
response, err := client.DebugCreateMachine(ctx, request)
|
||||
if err != nil {
|
||||
ErrorOutput(
|
||||
err,
|
||||
fmt.Sprintf("Cannot create machine: %s", status.Convert(err).Message()),
|
||||
output,
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
SuccessOutput(response.Machine, "Machine created", output)
|
||||
},
|
||||
}
|
||||
42
cmd/headscale/cli/generate.go
Normal file
42
cmd/headscale/cli/generate.go
Normal file
@@ -0,0 +1,42 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"tailscale.com/types/key"
|
||||
)
|
||||
|
||||
func init() {
|
||||
rootCmd.AddCommand(generateCmd)
|
||||
generateCmd.AddCommand(generatePrivateKeyCmd)
|
||||
}
|
||||
|
||||
var generateCmd = &cobra.Command{
|
||||
Use: "generate",
|
||||
Short: "Generate commands",
|
||||
Aliases: []string{"gen"},
|
||||
}
|
||||
|
||||
var generatePrivateKeyCmd = &cobra.Command{
|
||||
Use: "private-key",
|
||||
Short: "Generate a private key for the headscale server",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
output, _ := cmd.Flags().GetString("output")
|
||||
machineKey := key.NewMachine()
|
||||
|
||||
machineKeyStr, err := machineKey.MarshalText()
|
||||
if err != nil {
|
||||
ErrorOutput(
|
||||
err,
|
||||
fmt.Sprintf("Error getting machine key from flag: %s", err),
|
||||
output,
|
||||
)
|
||||
}
|
||||
|
||||
SuccessOutput(map[string]string{
|
||||
"private_key": string(machineKeyStr),
|
||||
},
|
||||
string(machineKeyStr), output)
|
||||
},
|
||||
}
|
||||
@@ -2,94 +2,242 @@ package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
|
||||
survey "github.com/AlecAivazis/survey/v2"
|
||||
"github.com/juanfont/headscale"
|
||||
v1 "github.com/juanfont/headscale/gen/go/headscale/v1"
|
||||
"github.com/pterm/pterm"
|
||||
"github.com/rs/zerolog/log"
|
||||
"github.com/spf13/cobra"
|
||||
"google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
var NamespaceCmd = &cobra.Command{
|
||||
Use: "namespaces",
|
||||
Short: "Manage the namespaces of Headscale",
|
||||
func init() {
|
||||
rootCmd.AddCommand(namespaceCmd)
|
||||
namespaceCmd.AddCommand(createNamespaceCmd)
|
||||
namespaceCmd.AddCommand(listNamespacesCmd)
|
||||
namespaceCmd.AddCommand(destroyNamespaceCmd)
|
||||
namespaceCmd.AddCommand(renameNamespaceCmd)
|
||||
}
|
||||
|
||||
var CreateNamespaceCmd = &cobra.Command{
|
||||
Use: "create NAME",
|
||||
Short: "Creates a new namespace",
|
||||
const (
|
||||
errMissingParameter = headscale.Error("missing parameters")
|
||||
)
|
||||
|
||||
var namespaceCmd = &cobra.Command{
|
||||
Use: "namespaces",
|
||||
Short: "Manage the namespaces of Headscale",
|
||||
Aliases: []string{"namespace", "ns", "user", "users"},
|
||||
}
|
||||
|
||||
var createNamespaceCmd = &cobra.Command{
|
||||
Use: "create NAME",
|
||||
Short: "Creates a new namespace",
|
||||
Aliases: []string{"c", "new"},
|
||||
Args: func(cmd *cobra.Command, args []string) error {
|
||||
if len(args) < 1 {
|
||||
return fmt.Errorf("Missing parameters")
|
||||
return errMissingParameter
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
o, _ := cmd.Flags().GetString("output")
|
||||
h, err := getHeadscaleApp()
|
||||
output, _ := cmd.Flags().GetString("output")
|
||||
|
||||
namespaceName := args[0]
|
||||
|
||||
ctx, client, conn, cancel := getHeadscaleCLIClient()
|
||||
defer cancel()
|
||||
defer conn.Close()
|
||||
|
||||
log.Trace().Interface("client", client).Msg("Obtained gRPC client")
|
||||
|
||||
request := &v1.CreateNamespaceRequest{Name: namespaceName}
|
||||
|
||||
log.Trace().Interface("request", request).Msg("Sending CreateNamespace request")
|
||||
response, err := client.CreateNamespace(ctx, request)
|
||||
if err != nil {
|
||||
log.Fatalf("Error initializing: %s", err)
|
||||
}
|
||||
namespace, err := h.CreateNamespace(args[0])
|
||||
if strings.HasPrefix(o, "json") {
|
||||
JsonOutput(namespace, err, o)
|
||||
ErrorOutput(
|
||||
err,
|
||||
fmt.Sprintf(
|
||||
"Cannot create namespace: %s",
|
||||
status.Convert(err).Message(),
|
||||
),
|
||||
output,
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
fmt.Printf("Error creating namespace: %s\n", err)
|
||||
return
|
||||
}
|
||||
fmt.Printf("Namespace created\n")
|
||||
|
||||
SuccessOutput(response.Namespace, "Namespace created", output)
|
||||
},
|
||||
}
|
||||
|
||||
var DestroyNamespaceCmd = &cobra.Command{
|
||||
Use: "destroy NAME",
|
||||
Short: "Destroys a namespace",
|
||||
var destroyNamespaceCmd = &cobra.Command{
|
||||
Use: "destroy NAME",
|
||||
Short: "Destroys a namespace",
|
||||
Aliases: []string{"delete"},
|
||||
Args: func(cmd *cobra.Command, args []string) error {
|
||||
if len(args) < 1 {
|
||||
return fmt.Errorf("Missing parameters")
|
||||
return errMissingParameter
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
o, _ := cmd.Flags().GetString("output")
|
||||
h, err := getHeadscaleApp()
|
||||
if err != nil {
|
||||
log.Fatalf("Error initializing: %s", err)
|
||||
output, _ := cmd.Flags().GetString("output")
|
||||
|
||||
namespaceName := args[0]
|
||||
|
||||
request := &v1.GetNamespaceRequest{
|
||||
Name: namespaceName,
|
||||
}
|
||||
err = h.DestroyNamespace(args[0])
|
||||
if strings.HasPrefix(o, "json") {
|
||||
JsonOutput(map[string]string{"Result": "Namespace destroyed"}, err, o)
|
||||
|
||||
ctx, client, conn, cancel := getHeadscaleCLIClient()
|
||||
defer cancel()
|
||||
defer conn.Close()
|
||||
|
||||
_, err := client.GetNamespace(ctx, request)
|
||||
if err != nil {
|
||||
ErrorOutput(
|
||||
err,
|
||||
fmt.Sprintf("Error: %s", status.Convert(err).Message()),
|
||||
output,
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
fmt.Printf("Error destroying namespace: %s\n", err)
|
||||
return
|
||||
|
||||
confirm := false
|
||||
force, _ := cmd.Flags().GetBool("force")
|
||||
if !force {
|
||||
prompt := &survey.Confirm{
|
||||
Message: fmt.Sprintf(
|
||||
"Do you want to remove the namespace '%s' and any associated preauthkeys?",
|
||||
namespaceName,
|
||||
),
|
||||
}
|
||||
err := survey.AskOne(prompt, &confirm)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if confirm || force {
|
||||
request := &v1.DeleteNamespaceRequest{Name: namespaceName}
|
||||
|
||||
response, err := client.DeleteNamespace(ctx, request)
|
||||
if err != nil {
|
||||
ErrorOutput(
|
||||
err,
|
||||
fmt.Sprintf(
|
||||
"Cannot destroy namespace: %s",
|
||||
status.Convert(err).Message(),
|
||||
),
|
||||
output,
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
SuccessOutput(response, "Namespace destroyed", output)
|
||||
} else {
|
||||
SuccessOutput(map[string]string{"Result": "Namespace not destroyed"}, "Namespace not destroyed", output)
|
||||
}
|
||||
fmt.Printf("Namespace destroyed\n")
|
||||
},
|
||||
}
|
||||
|
||||
var ListNamespacesCmd = &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List all the namespaces",
|
||||
var listNamespacesCmd = &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List all the namespaces",
|
||||
Aliases: []string{"ls", "show"},
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
o, _ := cmd.Flags().GetString("output")
|
||||
h, err := getHeadscaleApp()
|
||||
output, _ := cmd.Flags().GetString("output")
|
||||
|
||||
ctx, client, conn, cancel := getHeadscaleCLIClient()
|
||||
defer cancel()
|
||||
defer conn.Close()
|
||||
|
||||
request := &v1.ListNamespacesRequest{}
|
||||
|
||||
response, err := client.ListNamespaces(ctx, request)
|
||||
if err != nil {
|
||||
log.Fatalf("Error initializing: %s", err)
|
||||
}
|
||||
namespaces, err := h.ListNamespaces()
|
||||
if strings.HasPrefix(o, "json") {
|
||||
JsonOutput(namespaces, err, o)
|
||||
ErrorOutput(
|
||||
err,
|
||||
fmt.Sprintf("Cannot get namespaces: %s", status.Convert(err).Message()),
|
||||
output,
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
|
||||
if output != "" {
|
||||
SuccessOutput(response.Namespaces, "", output)
|
||||
|
||||
return
|
||||
}
|
||||
fmt.Printf("ID\tName\n")
|
||||
for _, n := range *namespaces {
|
||||
fmt.Printf("%d\t%s\n", n.ID, n.Name)
|
||||
|
||||
tableData := pterm.TableData{{"ID", "Name", "Created"}}
|
||||
for _, namespace := range response.GetNamespaces() {
|
||||
tableData = append(
|
||||
tableData,
|
||||
[]string{
|
||||
namespace.GetId(),
|
||||
namespace.GetName(),
|
||||
namespace.GetCreatedAt().AsTime().Format("2006-01-02 15:04:05"),
|
||||
},
|
||||
)
|
||||
}
|
||||
err = pterm.DefaultTable.WithHasHeader().WithData(tableData).Render()
|
||||
if err != nil {
|
||||
ErrorOutput(
|
||||
err,
|
||||
fmt.Sprintf("Failed to render pterm table: %s", err),
|
||||
output,
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
var renameNamespaceCmd = &cobra.Command{
|
||||
Use: "rename OLD_NAME NEW_NAME",
|
||||
Short: "Renames a namespace",
|
||||
Aliases: []string{"mv"},
|
||||
Args: func(cmd *cobra.Command, args []string) error {
|
||||
expectedArguments := 2
|
||||
if len(args) < expectedArguments {
|
||||
return errMissingParameter
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
output, _ := cmd.Flags().GetString("output")
|
||||
|
||||
ctx, client, conn, cancel := getHeadscaleCLIClient()
|
||||
defer cancel()
|
||||
defer conn.Close()
|
||||
|
||||
request := &v1.RenameNamespaceRequest{
|
||||
OldName: args[0],
|
||||
NewName: args[1],
|
||||
}
|
||||
|
||||
response, err := client.RenameNamespace(ctx, request)
|
||||
if err != nil {
|
||||
ErrorOutput(
|
||||
err,
|
||||
fmt.Sprintf(
|
||||
"Cannot rename namespace: %s",
|
||||
status.Convert(err).Message(),
|
||||
),
|
||||
output,
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
SuccessOutput(response.Namespace, "Namespace renamed", output)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -3,86 +3,381 @@ package cli
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
survey "github.com/AlecAivazis/survey/v2"
|
||||
"github.com/juanfont/headscale"
|
||||
v1 "github.com/juanfont/headscale/gen/go/headscale/v1"
|
||||
"github.com/pterm/pterm"
|
||||
"github.com/spf13/cobra"
|
||||
"google.golang.org/grpc/status"
|
||||
"tailscale.com/types/key"
|
||||
)
|
||||
|
||||
var NodeCmd = &cobra.Command{
|
||||
Use: "nodes",
|
||||
Short: "Manage the nodes of Headscale",
|
||||
func init() {
|
||||
rootCmd.AddCommand(nodeCmd)
|
||||
listNodesCmd.Flags().StringP("namespace", "n", "", "Filter by namespace")
|
||||
nodeCmd.AddCommand(listNodesCmd)
|
||||
|
||||
registerNodeCmd.Flags().StringP("namespace", "n", "", "Namespace")
|
||||
err := registerNodeCmd.MarkFlagRequired("namespace")
|
||||
if err != nil {
|
||||
log.Fatalf(err.Error())
|
||||
}
|
||||
registerNodeCmd.Flags().StringP("key", "k", "", "Key")
|
||||
err = registerNodeCmd.MarkFlagRequired("key")
|
||||
if err != nil {
|
||||
log.Fatalf(err.Error())
|
||||
}
|
||||
nodeCmd.AddCommand(registerNodeCmd)
|
||||
|
||||
expireNodeCmd.Flags().Uint64P("identifier", "i", 0, "Node identifier (ID)")
|
||||
err = expireNodeCmd.MarkFlagRequired("identifier")
|
||||
if err != nil {
|
||||
log.Fatalf(err.Error())
|
||||
}
|
||||
nodeCmd.AddCommand(expireNodeCmd)
|
||||
|
||||
deleteNodeCmd.Flags().Uint64P("identifier", "i", 0, "Node identifier (ID)")
|
||||
err = deleteNodeCmd.MarkFlagRequired("identifier")
|
||||
if err != nil {
|
||||
log.Fatalf(err.Error())
|
||||
}
|
||||
nodeCmd.AddCommand(deleteNodeCmd)
|
||||
}
|
||||
|
||||
var RegisterCmd = &cobra.Command{
|
||||
Use: "register machineID",
|
||||
var nodeCmd = &cobra.Command{
|
||||
Use: "nodes",
|
||||
Short: "Manage the nodes of Headscale",
|
||||
Aliases: []string{"node", "machine", "machines"},
|
||||
}
|
||||
|
||||
var registerNodeCmd = &cobra.Command{
|
||||
Use: "register",
|
||||
Short: "Registers a machine to your network",
|
||||
Args: func(cmd *cobra.Command, args []string) error {
|
||||
if len(args) < 1 {
|
||||
return fmt.Errorf("Missing parameters")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
n, err := cmd.Flags().GetString("namespace")
|
||||
output, _ := cmd.Flags().GetString("output")
|
||||
namespace, err := cmd.Flags().GetString("namespace")
|
||||
if err != nil {
|
||||
log.Fatalf("Error getting namespace: %s", err)
|
||||
}
|
||||
o, _ := cmd.Flags().GetString("output")
|
||||
ErrorOutput(err, fmt.Sprintf("Error getting namespace: %s", err), output)
|
||||
|
||||
h, err := getHeadscaleApp()
|
||||
if err != nil {
|
||||
log.Fatalf("Error initializing: %s", err)
|
||||
}
|
||||
m, err := h.RegisterMachine(args[0], n)
|
||||
if strings.HasPrefix(o, "json") {
|
||||
JsonOutput(m, err, o)
|
||||
return
|
||||
}
|
||||
|
||||
ctx, client, conn, cancel := getHeadscaleCLIClient()
|
||||
defer cancel()
|
||||
defer conn.Close()
|
||||
|
||||
machineKey, err := cmd.Flags().GetString("key")
|
||||
if err != nil {
|
||||
fmt.Printf("Cannot register machine: %s\n", err)
|
||||
ErrorOutput(
|
||||
err,
|
||||
fmt.Sprintf("Error getting machine key from flag: %s", err),
|
||||
output,
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
fmt.Printf("Machine registered\n")
|
||||
|
||||
request := &v1.RegisterMachineRequest{
|
||||
Key: machineKey,
|
||||
Namespace: namespace,
|
||||
}
|
||||
|
||||
response, err := client.RegisterMachine(ctx, request)
|
||||
if err != nil {
|
||||
ErrorOutput(
|
||||
err,
|
||||
fmt.Sprintf(
|
||||
"Cannot register machine: %s\n",
|
||||
status.Convert(err).Message(),
|
||||
),
|
||||
output,
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
SuccessOutput(response.Machine, "Machine register", output)
|
||||
},
|
||||
}
|
||||
|
||||
var ListNodesCmd = &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List the nodes in a given namespace",
|
||||
var listNodesCmd = &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List nodes",
|
||||
Aliases: []string{"ls", "show"},
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
n, err := cmd.Flags().GetString("namespace")
|
||||
output, _ := cmd.Flags().GetString("output")
|
||||
namespace, err := cmd.Flags().GetString("namespace")
|
||||
if err != nil {
|
||||
log.Fatalf("Error getting namespace: %s", err)
|
||||
}
|
||||
o, _ := cmd.Flags().GetString("output")
|
||||
ErrorOutput(err, fmt.Sprintf("Error getting namespace: %s", err), output)
|
||||
|
||||
h, err := getHeadscaleApp()
|
||||
if err != nil {
|
||||
log.Fatalf("Error initializing: %s", err)
|
||||
}
|
||||
machines, err := h.ListMachinesInNamespace(n)
|
||||
if strings.HasPrefix(o, "json") {
|
||||
JsonOutput(machines, err, o)
|
||||
return
|
||||
}
|
||||
|
||||
ctx, client, conn, cancel := getHeadscaleCLIClient()
|
||||
defer cancel()
|
||||
defer conn.Close()
|
||||
|
||||
request := &v1.ListMachinesRequest{
|
||||
Namespace: namespace,
|
||||
}
|
||||
|
||||
response, err := client.ListMachines(ctx, request)
|
||||
if err != nil {
|
||||
log.Fatalf("Error getting nodes: %s", err)
|
||||
ErrorOutput(
|
||||
err,
|
||||
fmt.Sprintf("Cannot get nodes: %s", status.Convert(err).Message()),
|
||||
output,
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Printf("name\t\tlast seen\t\tephemeral\n")
|
||||
for _, m := range *machines {
|
||||
var ephemeral bool
|
||||
if m.AuthKey != nil && m.AuthKey.Ephemeral {
|
||||
ephemeral = true
|
||||
}
|
||||
var lastSeen time.Time
|
||||
if m.LastSeen != nil {
|
||||
lastSeen = *m.LastSeen
|
||||
}
|
||||
fmt.Printf("%s\t%s\t%t\n", m.Name, lastSeen.Format("2006-01-02 15:04:05"), ephemeral)
|
||||
if output != "" {
|
||||
SuccessOutput(response.Machines, "", output)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
tableData, err := nodesToPtables(namespace, response.Machines)
|
||||
if err != nil {
|
||||
ErrorOutput(err, fmt.Sprintf("Error converting to table: %s", err), output)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
err = pterm.DefaultTable.WithHasHeader().WithData(tableData).Render()
|
||||
if err != nil {
|
||||
ErrorOutput(
|
||||
err,
|
||||
fmt.Sprintf("Failed to render pterm table: %s", err),
|
||||
output,
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
var expireNodeCmd = &cobra.Command{
|
||||
Use: "expire",
|
||||
Short: "Expire (log out) a machine in your network",
|
||||
Long: "Expiring a node will keep the node in the database and force it to reauthenticate.",
|
||||
Aliases: []string{"logout", "exp", "e"},
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
output, _ := cmd.Flags().GetString("output")
|
||||
|
||||
identifier, err := cmd.Flags().GetUint64("identifier")
|
||||
if err != nil {
|
||||
ErrorOutput(
|
||||
err,
|
||||
fmt.Sprintf("Error converting ID to integer: %s", err),
|
||||
output,
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
ctx, client, conn, cancel := getHeadscaleCLIClient()
|
||||
defer cancel()
|
||||
defer conn.Close()
|
||||
|
||||
request := &v1.ExpireMachineRequest{
|
||||
MachineId: identifier,
|
||||
}
|
||||
|
||||
response, err := client.ExpireMachine(ctx, request)
|
||||
if err != nil {
|
||||
ErrorOutput(
|
||||
err,
|
||||
fmt.Sprintf(
|
||||
"Cannot expire machine: %s\n",
|
||||
status.Convert(err).Message(),
|
||||
),
|
||||
output,
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
SuccessOutput(response.Machine, "Machine expired", output)
|
||||
},
|
||||
}
|
||||
|
||||
var deleteNodeCmd = &cobra.Command{
|
||||
Use: "delete",
|
||||
Short: "Delete a node",
|
||||
Aliases: []string{"del"},
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
output, _ := cmd.Flags().GetString("output")
|
||||
|
||||
identifier, err := cmd.Flags().GetUint64("identifier")
|
||||
if err != nil {
|
||||
ErrorOutput(
|
||||
err,
|
||||
fmt.Sprintf("Error converting ID to integer: %s", err),
|
||||
output,
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
ctx, client, conn, cancel := getHeadscaleCLIClient()
|
||||
defer cancel()
|
||||
defer conn.Close()
|
||||
|
||||
getRequest := &v1.GetMachineRequest{
|
||||
MachineId: identifier,
|
||||
}
|
||||
|
||||
getResponse, err := client.GetMachine(ctx, getRequest)
|
||||
if err != nil {
|
||||
ErrorOutput(
|
||||
err,
|
||||
fmt.Sprintf(
|
||||
"Error getting node node: %s",
|
||||
status.Convert(err).Message(),
|
||||
),
|
||||
output,
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
deleteRequest := &v1.DeleteMachineRequest{
|
||||
MachineId: identifier,
|
||||
}
|
||||
|
||||
confirm := false
|
||||
force, _ := cmd.Flags().GetBool("force")
|
||||
if !force {
|
||||
prompt := &survey.Confirm{
|
||||
Message: fmt.Sprintf(
|
||||
"Do you want to remove the node %s?",
|
||||
getResponse.GetMachine().Name,
|
||||
),
|
||||
}
|
||||
err = survey.AskOne(prompt, &confirm)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if confirm || force {
|
||||
response, err := client.DeleteMachine(ctx, deleteRequest)
|
||||
if output != "" {
|
||||
SuccessOutput(response, "", output)
|
||||
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
ErrorOutput(
|
||||
err,
|
||||
fmt.Sprintf(
|
||||
"Error deleting node: %s",
|
||||
status.Convert(err).Message(),
|
||||
),
|
||||
output,
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
SuccessOutput(
|
||||
map[string]string{"Result": "Node deleted"},
|
||||
"Node deleted",
|
||||
output,
|
||||
)
|
||||
} else {
|
||||
SuccessOutput(map[string]string{"Result": "Node not deleted"}, "Node not deleted", output)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
func nodesToPtables(
|
||||
currentNamespace string,
|
||||
machines []*v1.Machine,
|
||||
) (pterm.TableData, error) {
|
||||
tableData := pterm.TableData{
|
||||
{
|
||||
"ID",
|
||||
"Name",
|
||||
"NodeKey",
|
||||
"Namespace",
|
||||
"IP addresses",
|
||||
"Ephemeral",
|
||||
"Last seen",
|
||||
"Online",
|
||||
"Expired",
|
||||
},
|
||||
}
|
||||
|
||||
for _, machine := range machines {
|
||||
var ephemeral bool
|
||||
if machine.PreAuthKey != nil && machine.PreAuthKey.Ephemeral {
|
||||
ephemeral = true
|
||||
}
|
||||
|
||||
var lastSeen time.Time
|
||||
var lastSeenTime string
|
||||
if machine.LastSeen != nil {
|
||||
lastSeen = machine.LastSeen.AsTime()
|
||||
lastSeenTime = lastSeen.Format("2006-01-02 15:04:05")
|
||||
}
|
||||
|
||||
var expiry time.Time
|
||||
if machine.Expiry != nil {
|
||||
expiry = machine.Expiry.AsTime()
|
||||
}
|
||||
|
||||
var nodeKey key.NodePublic
|
||||
err := nodeKey.UnmarshalText(
|
||||
[]byte(headscale.NodePublicKeyEnsurePrefix(machine.NodeKey)),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var online string
|
||||
if lastSeen.After(
|
||||
time.Now().Add(-5 * time.Minute),
|
||||
) { // TODO: Find a better way to reliably show if online
|
||||
online = pterm.LightGreen("online")
|
||||
} else {
|
||||
online = pterm.LightRed("offline")
|
||||
}
|
||||
|
||||
var expired string
|
||||
if expiry.IsZero() || expiry.After(time.Now()) {
|
||||
expired = pterm.LightGreen("no")
|
||||
} else {
|
||||
expired = pterm.LightRed("yes")
|
||||
}
|
||||
|
||||
var namespace string
|
||||
if currentNamespace == "" || (currentNamespace == machine.Namespace.Name) {
|
||||
namespace = pterm.LightMagenta(machine.Namespace.Name)
|
||||
} else {
|
||||
// Shared into this namespace
|
||||
namespace = pterm.LightYellow(machine.Namespace.Name)
|
||||
}
|
||||
tableData = append(
|
||||
tableData,
|
||||
[]string{
|
||||
strconv.FormatUint(machine.Id, headscale.Base10),
|
||||
machine.Name,
|
||||
nodeKey.ShortString(),
|
||||
namespace,
|
||||
strings.Join(machine.IpAddresses, ", "),
|
||||
strconv.FormatBool(ephemeral),
|
||||
lastSeenTime,
|
||||
online,
|
||||
expired,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
return tableData, nil
|
||||
}
|
||||
|
||||
@@ -2,106 +2,218 @@ package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/hako/durafmt"
|
||||
v1 "github.com/juanfont/headscale/gen/go/headscale/v1"
|
||||
"github.com/pterm/pterm"
|
||||
"github.com/rs/zerolog/log"
|
||||
"github.com/spf13/cobra"
|
||||
"google.golang.org/protobuf/types/known/timestamppb"
|
||||
)
|
||||
|
||||
var PreauthkeysCmd = &cobra.Command{
|
||||
Use: "preauthkeys",
|
||||
Short: "Handle the preauthkeys in Headscale",
|
||||
const (
|
||||
DefaultPreAuthKeyExpiry = 1 * time.Hour
|
||||
)
|
||||
|
||||
func init() {
|
||||
rootCmd.AddCommand(preauthkeysCmd)
|
||||
preauthkeysCmd.PersistentFlags().StringP("namespace", "n", "", "Namespace")
|
||||
err := preauthkeysCmd.MarkPersistentFlagRequired("namespace")
|
||||
if err != nil {
|
||||
log.Fatal().Err(err).Msg("")
|
||||
}
|
||||
preauthkeysCmd.AddCommand(listPreAuthKeys)
|
||||
preauthkeysCmd.AddCommand(createPreAuthKeyCmd)
|
||||
preauthkeysCmd.AddCommand(expirePreAuthKeyCmd)
|
||||
createPreAuthKeyCmd.PersistentFlags().
|
||||
Bool("reusable", false, "Make the preauthkey reusable")
|
||||
createPreAuthKeyCmd.PersistentFlags().
|
||||
Bool("ephemeral", false, "Preauthkey for ephemeral nodes")
|
||||
createPreAuthKeyCmd.Flags().
|
||||
DurationP("expiration", "e", DefaultPreAuthKeyExpiry, "Human-readable expiration of the key (30m, 24h, 365d...)")
|
||||
}
|
||||
|
||||
var ListPreAuthKeys = &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List the preauthkeys for this namespace",
|
||||
var preauthkeysCmd = &cobra.Command{
|
||||
Use: "preauthkeys",
|
||||
Short: "Handle the preauthkeys in Headscale",
|
||||
Aliases: []string{"preauthkey", "authkey", "pre"},
|
||||
}
|
||||
|
||||
var listPreAuthKeys = &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List the preauthkeys for this namespace",
|
||||
Aliases: []string{"ls", "show"},
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
n, err := cmd.Flags().GetString("namespace")
|
||||
if err != nil {
|
||||
log.Fatalf("Error getting namespace: %s", err)
|
||||
}
|
||||
o, _ := cmd.Flags().GetString("output")
|
||||
output, _ := cmd.Flags().GetString("output")
|
||||
|
||||
h, err := getHeadscaleApp()
|
||||
namespace, err := cmd.Flags().GetString("namespace")
|
||||
if err != nil {
|
||||
log.Fatalf("Error initializing: %s", err)
|
||||
}
|
||||
keys, err := h.GetPreAuthKeys(n)
|
||||
if strings.HasPrefix(o, "json") {
|
||||
JsonOutput(keys, err, o)
|
||||
ErrorOutput(err, fmt.Sprintf("Error getting namespace: %s", err), output)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
ctx, client, conn, cancel := getHeadscaleCLIClient()
|
||||
defer cancel()
|
||||
defer conn.Close()
|
||||
|
||||
request := &v1.ListPreAuthKeysRequest{
|
||||
Namespace: namespace,
|
||||
}
|
||||
|
||||
response, err := client.ListPreAuthKeys(ctx, request)
|
||||
if err != nil {
|
||||
fmt.Printf("Error getting the list of keys: %s\n", err)
|
||||
ErrorOutput(
|
||||
err,
|
||||
fmt.Sprintf("Error getting the list of keys: %s", err),
|
||||
output,
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
for _, k := range *keys {
|
||||
|
||||
if output != "" {
|
||||
SuccessOutput(response.PreAuthKeys, "", output)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
tableData := pterm.TableData{
|
||||
{"ID", "Key", "Reusable", "Ephemeral", "Used", "Expiration", "Created"},
|
||||
}
|
||||
for _, key := range response.PreAuthKeys {
|
||||
expiration := "-"
|
||||
if k.Expiration != nil {
|
||||
expiration = k.Expiration.Format("2006-01-02 15:04:05")
|
||||
if key.GetExpiration() != nil {
|
||||
expiration = ColourTime(key.Expiration.AsTime())
|
||||
}
|
||||
|
||||
var reusable string
|
||||
if k.Ephemeral {
|
||||
if key.GetEphemeral() {
|
||||
reusable = "N/A"
|
||||
} else {
|
||||
reusable = fmt.Sprintf("%v", k.Reusable)
|
||||
reusable = fmt.Sprintf("%v", key.GetReusable())
|
||||
}
|
||||
|
||||
fmt.Printf(
|
||||
"key: %s, namespace: %s, reusable: %s, ephemeral: %v, expiration: %s, created_at: %s\n",
|
||||
k.Key,
|
||||
k.Namespace.Name,
|
||||
tableData = append(tableData, []string{
|
||||
key.GetId(),
|
||||
key.GetKey(),
|
||||
reusable,
|
||||
k.Ephemeral,
|
||||
strconv.FormatBool(key.GetEphemeral()),
|
||||
strconv.FormatBool(key.GetUsed()),
|
||||
expiration,
|
||||
k.CreatedAt.Format("2006-01-02 15:04:05"),
|
||||
key.GetCreatedAt().AsTime().Format("2006-01-02 15:04:05"),
|
||||
})
|
||||
|
||||
}
|
||||
err = pterm.DefaultTable.WithHasHeader().WithData(tableData).Render()
|
||||
if err != nil {
|
||||
ErrorOutput(
|
||||
err,
|
||||
fmt.Sprintf("Failed to render pterm table: %s", err),
|
||||
output,
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
var CreatePreAuthKeyCmd = &cobra.Command{
|
||||
Use: "create",
|
||||
Short: "Creates a new preauthkey in the specified namespace",
|
||||
var createPreAuthKeyCmd = &cobra.Command{
|
||||
Use: "create",
|
||||
Short: "Creates a new preauthkey in the specified namespace",
|
||||
Aliases: []string{"c", "new"},
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
n, err := cmd.Flags().GetString("namespace")
|
||||
if err != nil {
|
||||
log.Fatalf("Error getting namespace: %s", err)
|
||||
}
|
||||
o, _ := cmd.Flags().GetString("output")
|
||||
output, _ := cmd.Flags().GetString("output")
|
||||
|
||||
h, err := getHeadscaleApp()
|
||||
namespace, err := cmd.Flags().GetString("namespace")
|
||||
if err != nil {
|
||||
log.Fatalf("Error initializing: %s", err)
|
||||
ErrorOutput(err, fmt.Sprintf("Error getting namespace: %s", err), output)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
reusable, _ := cmd.Flags().GetBool("reusable")
|
||||
ephemeral, _ := cmd.Flags().GetBool("ephemeral")
|
||||
|
||||
e, _ := cmd.Flags().GetString("expiration")
|
||||
var expiration *time.Time
|
||||
if e != "" {
|
||||
duration, err := durafmt.ParseStringShort(e)
|
||||
if err != nil {
|
||||
log.Fatalf("Error parsing expiration: %s", err)
|
||||
}
|
||||
exp := time.Now().UTC().Add(duration.Duration())
|
||||
expiration = &exp
|
||||
log.Trace().
|
||||
Bool("reusable", reusable).
|
||||
Bool("ephemeral", ephemeral).
|
||||
Str("namespace", namespace).
|
||||
Msg("Preparing to create preauthkey")
|
||||
|
||||
request := &v1.CreatePreAuthKeyRequest{
|
||||
Namespace: namespace,
|
||||
Reusable: reusable,
|
||||
Ephemeral: ephemeral,
|
||||
}
|
||||
|
||||
k, err := h.CreatePreAuthKey(n, reusable, ephemeral, expiration)
|
||||
if strings.HasPrefix(o, "json") {
|
||||
JsonOutput(k, err, o)
|
||||
return
|
||||
}
|
||||
duration, _ := cmd.Flags().GetDuration("expiration")
|
||||
expiration := time.Now().UTC().Add(duration)
|
||||
|
||||
log.Trace().Dur("expiration", duration).Msg("expiration has been set")
|
||||
|
||||
request.Expiration = timestamppb.New(expiration)
|
||||
|
||||
ctx, client, conn, cancel := getHeadscaleCLIClient()
|
||||
defer cancel()
|
||||
defer conn.Close()
|
||||
|
||||
response, err := client.CreatePreAuthKey(ctx, request)
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
ErrorOutput(
|
||||
err,
|
||||
fmt.Sprintf("Cannot create Pre Auth Key: %s\n", err),
|
||||
output,
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
fmt.Printf("Key: %s\n", k.Key)
|
||||
|
||||
SuccessOutput(response.PreAuthKey, response.PreAuthKey.Key, output)
|
||||
},
|
||||
}
|
||||
|
||||
var expirePreAuthKeyCmd = &cobra.Command{
|
||||
Use: "expire KEY",
|
||||
Short: "Expire a preauthkey",
|
||||
Aliases: []string{"revoke", "exp", "e"},
|
||||
Args: func(cmd *cobra.Command, args []string) error {
|
||||
if len(args) < 1 {
|
||||
return errMissingParameter
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
output, _ := cmd.Flags().GetString("output")
|
||||
namespace, err := cmd.Flags().GetString("namespace")
|
||||
if err != nil {
|
||||
ErrorOutput(err, fmt.Sprintf("Error getting namespace: %s", err), output)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
ctx, client, conn, cancel := getHeadscaleCLIClient()
|
||||
defer cancel()
|
||||
defer conn.Close()
|
||||
|
||||
request := &v1.ExpirePreAuthKeyRequest{
|
||||
Namespace: namespace,
|
||||
Key: args[0],
|
||||
}
|
||||
|
||||
response, err := client.ExpirePreAuthKey(ctx, request)
|
||||
if err != nil {
|
||||
ErrorOutput(
|
||||
err,
|
||||
fmt.Sprintf("Cannot expire Pre Auth Key: %s\n", err),
|
||||
output,
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
SuccessOutput(response, "Key expired", output)
|
||||
},
|
||||
}
|
||||
|
||||
19
cmd/headscale/cli/pterm_style.go
Normal file
19
cmd/headscale/cli/pterm_style.go
Normal file
@@ -0,0 +1,19 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/pterm/pterm"
|
||||
)
|
||||
|
||||
func ColourTime(date time.Time) string {
|
||||
dateStr := date.Format("2006-01-02 15:04:05")
|
||||
|
||||
if date.After(time.Now()) {
|
||||
dateStr = pterm.LightGreen(dateStr)
|
||||
} else {
|
||||
dateStr = pterm.LightRed(dateStr)
|
||||
}
|
||||
|
||||
return dateStr
|
||||
}
|
||||
31
cmd/headscale/cli/root.go
Normal file
31
cmd/headscale/cli/root.go
Normal file
@@ -0,0 +1,31 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func init() {
|
||||
rootCmd.PersistentFlags().
|
||||
StringP("output", "o", "", "Output format. Empty for human-readable, 'json', 'json-line' or 'yaml'")
|
||||
rootCmd.PersistentFlags().
|
||||
Bool("force", false, "Disable prompts and forces the execution")
|
||||
}
|
||||
|
||||
var rootCmd = &cobra.Command{
|
||||
Use: "headscale",
|
||||
Short: "headscale - a Tailscale control server",
|
||||
Long: `
|
||||
headscale is an open source implementation of the Tailscale control server
|
||||
|
||||
https://github.com/juanfont/headscale`,
|
||||
}
|
||||
|
||||
func Execute() {
|
||||
if err := rootCmd.Execute(); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
@@ -3,82 +3,207 @@ package cli
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"strconv"
|
||||
|
||||
v1 "github.com/juanfont/headscale/gen/go/headscale/v1"
|
||||
"github.com/pterm/pterm"
|
||||
"github.com/spf13/cobra"
|
||||
"google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
var RoutesCmd = &cobra.Command{
|
||||
Use: "routes",
|
||||
Short: "Manage the routes of Headscale",
|
||||
func init() {
|
||||
rootCmd.AddCommand(routesCmd)
|
||||
|
||||
listRoutesCmd.Flags().Uint64P("identifier", "i", 0, "Node identifier (ID)")
|
||||
err := listRoutesCmd.MarkFlagRequired("identifier")
|
||||
if err != nil {
|
||||
log.Fatalf(err.Error())
|
||||
}
|
||||
routesCmd.AddCommand(listRoutesCmd)
|
||||
|
||||
enableRouteCmd.Flags().
|
||||
StringSliceP("route", "r", []string{}, "List (or repeated flags) of routes to enable")
|
||||
enableRouteCmd.Flags().Uint64P("identifier", "i", 0, "Node identifier (ID)")
|
||||
err = enableRouteCmd.MarkFlagRequired("identifier")
|
||||
if err != nil {
|
||||
log.Fatalf(err.Error())
|
||||
}
|
||||
|
||||
routesCmd.AddCommand(enableRouteCmd)
|
||||
|
||||
nodeCmd.AddCommand(routesCmd)
|
||||
}
|
||||
|
||||
var ListRoutesCmd = &cobra.Command{
|
||||
Use: "list NODE",
|
||||
Short: "List the routes exposed by this node",
|
||||
Args: func(cmd *cobra.Command, args []string) error {
|
||||
if len(args) < 1 {
|
||||
return fmt.Errorf("Missing parameters")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
var routesCmd = &cobra.Command{
|
||||
Use: "routes",
|
||||
Short: "Manage the routes of Headscale",
|
||||
Aliases: []string{"r", "route"},
|
||||
}
|
||||
|
||||
var listRoutesCmd = &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List routes advertised and enabled by a given node",
|
||||
Aliases: []string{"ls", "show"},
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
n, err := cmd.Flags().GetString("namespace")
|
||||
if err != nil {
|
||||
log.Fatalf("Error getting namespace: %s", err)
|
||||
}
|
||||
o, _ := cmd.Flags().GetString("output")
|
||||
output, _ := cmd.Flags().GetString("output")
|
||||
|
||||
h, err := getHeadscaleApp()
|
||||
machineID, err := cmd.Flags().GetUint64("identifier")
|
||||
if err != nil {
|
||||
log.Fatalf("Error initializing: %s", err)
|
||||
}
|
||||
routes, err := h.GetNodeRoutes(n, args[0])
|
||||
ErrorOutput(
|
||||
err,
|
||||
fmt.Sprintf("Error getting machine id from flag: %s", err),
|
||||
output,
|
||||
)
|
||||
|
||||
if strings.HasPrefix(o, "json") {
|
||||
JsonOutput(routes, err, o)
|
||||
return
|
||||
}
|
||||
|
||||
ctx, client, conn, cancel := getHeadscaleCLIClient()
|
||||
defer cancel()
|
||||
defer conn.Close()
|
||||
|
||||
request := &v1.GetMachineRouteRequest{
|
||||
MachineId: machineID,
|
||||
}
|
||||
|
||||
response, err := client.GetMachineRoute(ctx, request)
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
ErrorOutput(
|
||||
err,
|
||||
fmt.Sprintf("Cannot get nodes: %s", status.Convert(err).Message()),
|
||||
output,
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Println(routes)
|
||||
if output != "" {
|
||||
SuccessOutput(response.Routes, "", output)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
tableData := routesToPtables(response.Routes)
|
||||
if err != nil {
|
||||
ErrorOutput(err, fmt.Sprintf("Error converting to table: %s", err), output)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
err = pterm.DefaultTable.WithHasHeader().WithData(tableData).Render()
|
||||
if err != nil {
|
||||
ErrorOutput(
|
||||
err,
|
||||
fmt.Sprintf("Failed to render pterm table: %s", err),
|
||||
output,
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
var EnableRouteCmd = &cobra.Command{
|
||||
Use: "enable node-name route",
|
||||
Short: "Allows exposing a route declared by this node to the rest of the nodes",
|
||||
Args: func(cmd *cobra.Command, args []string) error {
|
||||
if len(args) < 2 {
|
||||
return fmt.Errorf("Missing parameters")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
var enableRouteCmd = &cobra.Command{
|
||||
Use: "enable",
|
||||
Short: "Set the enabled routes for a given node",
|
||||
Long: `This command will take a list of routes that will _replace_
|
||||
the current set of routes on a given node.
|
||||
If you would like to disable a route, simply run the command again, but
|
||||
omit the route you do not want to enable.
|
||||
`,
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
n, err := cmd.Flags().GetString("namespace")
|
||||
if err != nil {
|
||||
log.Fatalf("Error getting namespace: %s", err)
|
||||
}
|
||||
o, _ := cmd.Flags().GetString("output")
|
||||
output, _ := cmd.Flags().GetString("output")
|
||||
|
||||
h, err := getHeadscaleApp()
|
||||
machineID, err := cmd.Flags().GetUint64("identifier")
|
||||
if err != nil {
|
||||
log.Fatalf("Error initializing: %s", err)
|
||||
}
|
||||
route, err := h.EnableNodeRoute(n, args[0], args[1])
|
||||
if strings.HasPrefix(o, "json") {
|
||||
JsonOutput(route, err, o)
|
||||
ErrorOutput(
|
||||
err,
|
||||
fmt.Sprintf("Error getting machine id from flag: %s", err),
|
||||
output,
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
routes, err := cmd.Flags().GetStringSlice("route")
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
ErrorOutput(
|
||||
err,
|
||||
fmt.Sprintf("Error getting routes from flag: %s", err),
|
||||
output,
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
ctx, client, conn, cancel := getHeadscaleCLIClient()
|
||||
defer cancel()
|
||||
defer conn.Close()
|
||||
|
||||
request := &v1.EnableMachineRoutesRequest{
|
||||
MachineId: machineID,
|
||||
Routes: routes,
|
||||
}
|
||||
|
||||
response, err := client.EnableMachineRoutes(ctx, request)
|
||||
if err != nil {
|
||||
ErrorOutput(
|
||||
err,
|
||||
fmt.Sprintf(
|
||||
"Cannot register machine: %s\n",
|
||||
status.Convert(err).Message(),
|
||||
),
|
||||
output,
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if output != "" {
|
||||
SuccessOutput(response.Routes, "", output)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
tableData := routesToPtables(response.Routes)
|
||||
if err != nil {
|
||||
ErrorOutput(err, fmt.Sprintf("Error converting to table: %s", err), output)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
err = pterm.DefaultTable.WithHasHeader().WithData(tableData).Render()
|
||||
if err != nil {
|
||||
ErrorOutput(
|
||||
err,
|
||||
fmt.Sprintf("Failed to render pterm table: %s", err),
|
||||
output,
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
fmt.Printf("Enabled route %s\n", route)
|
||||
},
|
||||
}
|
||||
|
||||
// routesToPtables converts the list of routes to a nice table.
|
||||
func routesToPtables(routes *v1.Routes) pterm.TableData {
|
||||
tableData := pterm.TableData{{"Route", "Enabled"}}
|
||||
|
||||
for _, route := range routes.GetAdvertisedRoutes() {
|
||||
enabled := isStringInSlice(routes.EnabledRoutes, route)
|
||||
|
||||
tableData = append(tableData, []string{route, strconv.FormatBool(enabled)})
|
||||
}
|
||||
|
||||
return tableData
|
||||
}
|
||||
|
||||
func isStringInSlice(strs []string, s string) bool {
|
||||
for _, s2 := range strs {
|
||||
if s == s2 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"log"
|
||||
|
||||
"github.com/rs/zerolog/log"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var ServeCmd = &cobra.Command{
|
||||
func init() {
|
||||
rootCmd.AddCommand(serveCmd)
|
||||
}
|
||||
|
||||
var serveCmd = &cobra.Command{
|
||||
Use: "serve",
|
||||
Short: "Launches the headscale server",
|
||||
Args: func(cmd *cobra.Command, args []string) error {
|
||||
@@ -15,12 +18,12 @@ var ServeCmd = &cobra.Command{
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
h, err := getHeadscaleApp()
|
||||
if err != nil {
|
||||
log.Fatalf("Error initializing: %s", err)
|
||||
log.Fatal().Caller().Err(err).Msg("Error initializing")
|
||||
}
|
||||
go h.ExpireEphemeralNodes(5000)
|
||||
|
||||
err = h.Serve()
|
||||
if err != nil {
|
||||
log.Fatalf("Error initializing: %s", err)
|
||||
log.Fatal().Caller().Err(err).Msg("Error starting server")
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,25 +1,36 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"io/fs"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/juanfont/headscale"
|
||||
v1 "github.com/juanfont/headscale/gen/go/headscale/v1"
|
||||
"github.com/rs/zerolog/log"
|
||||
"github.com/spf13/viper"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/credentials"
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
"gopkg.in/yaml.v2"
|
||||
"inet.af/netaddr"
|
||||
"tailscale.com/tailcfg"
|
||||
"tailscale.com/types/dnstype"
|
||||
)
|
||||
|
||||
type ErrorOutput struct {
|
||||
Error string
|
||||
}
|
||||
const (
|
||||
PermissionFallback = 0o700
|
||||
HeadscaleDateTimeFormat = "2006-01-02 15:04:05"
|
||||
)
|
||||
|
||||
func LoadConfig(path string) error {
|
||||
viper.SetConfigName("config")
|
||||
@@ -31,40 +42,214 @@ func LoadConfig(path string) error {
|
||||
// For testing
|
||||
viper.AddConfigPath(path)
|
||||
}
|
||||
|
||||
viper.SetEnvPrefix("headscale")
|
||||
viper.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
|
||||
viper.AutomaticEnv()
|
||||
|
||||
viper.SetDefault("tls_letsencrypt_cache_dir", "/var/www/.cache")
|
||||
viper.SetDefault("tls_letsencrypt_challenge_type", "HTTP-01")
|
||||
viper.SetDefault("tls_client_auth_mode", "relaxed")
|
||||
|
||||
err := viper.ReadInConfig()
|
||||
if err != nil {
|
||||
return fmt.Errorf("Fatal error reading config file: %s \n", err)
|
||||
viper.SetDefault("log_level", "info")
|
||||
|
||||
viper.SetDefault("dns_config", nil)
|
||||
|
||||
viper.SetDefault("derp.server.enabled", false)
|
||||
viper.SetDefault("derp.server.stun.enabled", true)
|
||||
|
||||
viper.SetDefault("unix_socket", "/var/run/headscale.sock")
|
||||
viper.SetDefault("unix_socket_permission", "0o770")
|
||||
|
||||
viper.SetDefault("grpc_listen_addr", ":50443")
|
||||
viper.SetDefault("grpc_allow_insecure", false)
|
||||
|
||||
viper.SetDefault("cli.timeout", "5s")
|
||||
viper.SetDefault("cli.insecure", false)
|
||||
|
||||
viper.SetDefault("oidc.strip_email_domain", true)
|
||||
|
||||
if err := viper.ReadInConfig(); err != nil {
|
||||
return fmt.Errorf("fatal error reading config file: %w", err)
|
||||
}
|
||||
|
||||
// Collect any validation errors and return them all at once
|
||||
var errorText string
|
||||
if (viper.GetString("tls_letsencrypt_hostname") != "") && ((viper.GetString("tls_cert_path") != "") || (viper.GetString("tls_key_path") != "")) {
|
||||
if (viper.GetString("tls_letsencrypt_hostname") != "") &&
|
||||
((viper.GetString("tls_cert_path") != "") || (viper.GetString("tls_key_path") != "")) {
|
||||
errorText += "Fatal config error: set either tls_letsencrypt_hostname or tls_cert_path/tls_key_path, not both\n"
|
||||
}
|
||||
|
||||
if (viper.GetString("tls_letsencrypt_hostname") != "") && (viper.GetString("tls_letsencrypt_challenge_type") == "TLS-ALPN-01") && (!strings.HasSuffix(viper.GetString("listen_addr"), ":443")) {
|
||||
errorText += "Fatal config error: when using tls_letsencrypt_hostname with TLS-ALPN-01 as challenge type, listen_addr must end in :443\n"
|
||||
if (viper.GetString("tls_letsencrypt_hostname") != "") &&
|
||||
(viper.GetString("tls_letsencrypt_challenge_type") == "TLS-ALPN-01") &&
|
||||
(!strings.HasSuffix(viper.GetString("listen_addr"), ":443")) {
|
||||
// this is only a warning because there could be something sitting in front of headscale that redirects the traffic (e.g. an iptables rule)
|
||||
log.Warn().
|
||||
Msg("Warning: when using tls_letsencrypt_hostname with TLS-ALPN-01 as challenge type, headscale must be reachable on port 443, i.e. listen_addr should probably end in :443")
|
||||
}
|
||||
|
||||
if (viper.GetString("tls_letsencrypt_challenge_type") != "HTTP-01") && (viper.GetString("tls_letsencrypt_challenge_type") != "TLS-ALPN-01") {
|
||||
if (viper.GetString("tls_letsencrypt_challenge_type") != "HTTP-01") &&
|
||||
(viper.GetString("tls_letsencrypt_challenge_type") != "TLS-ALPN-01") {
|
||||
errorText += "Fatal config error: the only supported values for tls_letsencrypt_challenge_type are HTTP-01 and TLS-ALPN-01\n"
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(viper.GetString("server_url"), "http://") && !strings.HasPrefix(viper.GetString("server_url"), "https://") {
|
||||
if !strings.HasPrefix(viper.GetString("server_url"), "http://") &&
|
||||
!strings.HasPrefix(viper.GetString("server_url"), "https://") {
|
||||
errorText += "Fatal config error: server_url must start with https:// or http://\n"
|
||||
}
|
||||
|
||||
_, authModeValid := headscale.LookupTLSClientAuthMode(
|
||||
viper.GetString("tls_client_auth_mode"),
|
||||
)
|
||||
|
||||
if !authModeValid {
|
||||
errorText += fmt.Sprintf(
|
||||
"Invalid tls_client_auth_mode supplied: %s. Accepted values: %s, %s, %s.",
|
||||
viper.GetString("tls_client_auth_mode"),
|
||||
headscale.DisabledClientAuth,
|
||||
headscale.RelaxedClientAuth,
|
||||
headscale.EnforcedClientAuth)
|
||||
}
|
||||
|
||||
if errorText != "" {
|
||||
//nolint
|
||||
return errors.New(strings.TrimSuffix(errorText, "\n"))
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func GetDERPConfig() headscale.DERPConfig {
|
||||
serverEnabled := viper.GetBool("derp.server.enabled")
|
||||
serverRegionID := viper.GetInt("derp.server.region_id")
|
||||
serverRegionCode := viper.GetString("derp.server.region_code")
|
||||
serverRegionName := viper.GetString("derp.server.region_name")
|
||||
stunAddr := viper.GetString("derp.server.stun_listen_addr")
|
||||
|
||||
if serverEnabled && stunAddr == "" {
|
||||
log.Fatal().Msg("derp.server.stun_listen_addr must be set if derp.server.enabled is true")
|
||||
}
|
||||
|
||||
urlStrs := viper.GetStringSlice("derp.urls")
|
||||
|
||||
urls := make([]url.URL, len(urlStrs))
|
||||
for index, urlStr := range urlStrs {
|
||||
urlAddr, err := url.Parse(urlStr)
|
||||
if err != nil {
|
||||
log.Error().
|
||||
Str("url", urlStr).
|
||||
Err(err).
|
||||
Msg("Failed to parse url, ignoring...")
|
||||
}
|
||||
|
||||
urls[index] = *urlAddr
|
||||
}
|
||||
|
||||
paths := viper.GetStringSlice("derp.paths")
|
||||
|
||||
autoUpdate := viper.GetBool("derp.auto_update_enabled")
|
||||
updateFrequency := viper.GetDuration("derp.update_frequency")
|
||||
|
||||
return headscale.DERPConfig{
|
||||
ServerEnabled: serverEnabled,
|
||||
ServerRegionID: serverRegionID,
|
||||
ServerRegionCode: serverRegionCode,
|
||||
ServerRegionName: serverRegionName,
|
||||
STUNAddr: stunAddr,
|
||||
URLs: urls,
|
||||
Paths: paths,
|
||||
AutoUpdate: autoUpdate,
|
||||
UpdateFrequency: updateFrequency,
|
||||
}
|
||||
}
|
||||
|
||||
func GetDNSConfig() (*tailcfg.DNSConfig, string) {
|
||||
if viper.IsSet("dns_config") {
|
||||
dnsConfig := &tailcfg.DNSConfig{}
|
||||
|
||||
if viper.IsSet("dns_config.nameservers") {
|
||||
nameserversStr := viper.GetStringSlice("dns_config.nameservers")
|
||||
|
||||
nameservers := make([]netaddr.IP, len(nameserversStr))
|
||||
resolvers := make([]dnstype.Resolver, len(nameserversStr))
|
||||
|
||||
for index, nameserverStr := range nameserversStr {
|
||||
nameserver, err := netaddr.ParseIP(nameserverStr)
|
||||
if err != nil {
|
||||
log.Error().
|
||||
Str("func", "getDNSConfig").
|
||||
Err(err).
|
||||
Msgf("Could not parse nameserver IP: %s", nameserverStr)
|
||||
}
|
||||
|
||||
nameservers[index] = nameserver
|
||||
resolvers[index] = dnstype.Resolver{
|
||||
Addr: nameserver.String(),
|
||||
}
|
||||
}
|
||||
|
||||
dnsConfig.Nameservers = nameservers
|
||||
dnsConfig.Resolvers = resolvers
|
||||
}
|
||||
|
||||
if viper.IsSet("dns_config.restricted_nameservers") {
|
||||
if len(dnsConfig.Nameservers) > 0 {
|
||||
dnsConfig.Routes = make(map[string][]dnstype.Resolver)
|
||||
restrictedDNS := viper.GetStringMapStringSlice(
|
||||
"dns_config.restricted_nameservers",
|
||||
)
|
||||
for domain, restrictedNameservers := range restrictedDNS {
|
||||
restrictedResolvers := make(
|
||||
[]dnstype.Resolver,
|
||||
len(restrictedNameservers),
|
||||
)
|
||||
for index, nameserverStr := range restrictedNameservers {
|
||||
nameserver, err := netaddr.ParseIP(nameserverStr)
|
||||
if err != nil {
|
||||
log.Error().
|
||||
Str("func", "getDNSConfig").
|
||||
Err(err).
|
||||
Msgf("Could not parse restricted nameserver IP: %s", nameserverStr)
|
||||
}
|
||||
restrictedResolvers[index] = dnstype.Resolver{
|
||||
Addr: nameserver.String(),
|
||||
}
|
||||
}
|
||||
dnsConfig.Routes[domain] = restrictedResolvers
|
||||
}
|
||||
} else {
|
||||
log.Warn().
|
||||
Msg("Warning: dns_config.restricted_nameservers is set, but no nameservers are configured. Ignoring restricted_nameservers.")
|
||||
}
|
||||
}
|
||||
|
||||
if viper.IsSet("dns_config.domains") {
|
||||
dnsConfig.Domains = viper.GetStringSlice("dns_config.domains")
|
||||
}
|
||||
|
||||
if viper.IsSet("dns_config.magic_dns") {
|
||||
magicDNS := viper.GetBool("dns_config.magic_dns")
|
||||
if len(dnsConfig.Nameservers) > 0 {
|
||||
dnsConfig.Proxied = magicDNS
|
||||
} else if magicDNS {
|
||||
log.Warn().
|
||||
Msg("Warning: dns_config.magic_dns is set, but no nameservers are configured. Ignoring magic_dns.")
|
||||
}
|
||||
}
|
||||
|
||||
var baseDomain string
|
||||
if viper.IsSet("dns_config.base_domain") {
|
||||
baseDomain = viper.GetString("dns_config.base_domain")
|
||||
} else {
|
||||
baseDomain = "headscale.net" // does not really matter when MagicDNS is not enabled
|
||||
}
|
||||
|
||||
return dnsConfig, baseDomain
|
||||
}
|
||||
|
||||
return nil, ""
|
||||
}
|
||||
|
||||
func absPath(path string) string {
|
||||
// If a relative path is provided, prefix it with the the directory where
|
||||
// the config file was found.
|
||||
@@ -74,30 +259,82 @@ func absPath(path string) string {
|
||||
path = filepath.Join(dir, path)
|
||||
}
|
||||
}
|
||||
|
||||
return path
|
||||
}
|
||||
|
||||
func getHeadscaleApp() (*headscale.Headscale, error) {
|
||||
derpMap, err := loadDerpMap(absPath(viper.GetString("derp_map_path")))
|
||||
if err != nil {
|
||||
log.Printf("Could not load DERP servers map file: %s", err)
|
||||
func getHeadscaleConfig() headscale.Config {
|
||||
dnsConfig, baseDomain := GetDNSConfig()
|
||||
derpConfig := GetDERPConfig()
|
||||
|
||||
configuredPrefixes := viper.GetStringSlice("ip_prefixes")
|
||||
parsedPrefixes := make([]netaddr.IPPrefix, 0, len(configuredPrefixes)+1)
|
||||
|
||||
legacyPrefixField := viper.GetString("ip_prefix")
|
||||
if len(legacyPrefixField) > 0 {
|
||||
log.
|
||||
Warn().
|
||||
Msgf(
|
||||
"%s, %s",
|
||||
"use of 'ip_prefix' for configuration is deprecated",
|
||||
"please see 'ip_prefixes' in the shipped example.",
|
||||
)
|
||||
legacyPrefix, err := netaddr.ParseIPPrefix(legacyPrefixField)
|
||||
if err != nil {
|
||||
panic(fmt.Errorf("failed to parse ip_prefix: %w", err))
|
||||
}
|
||||
parsedPrefixes = append(parsedPrefixes, legacyPrefix)
|
||||
}
|
||||
|
||||
// Minimum inactivity time out is keepalive timeout (60s) plus a few seconds
|
||||
// to avoid races
|
||||
minInactivityTimeout, _ := time.ParseDuration("65s")
|
||||
if viper.GetDuration("ephemeral_node_inactivity_timeout") <= minInactivityTimeout {
|
||||
err = fmt.Errorf("ephemeral_node_inactivity_timeout (%s) is set too low, must be more than %s\n", viper.GetString("ephemeral_node_inactivity_timeout"), minInactivityTimeout)
|
||||
return nil, err
|
||||
for i, prefixInConfig := range configuredPrefixes {
|
||||
prefix, err := netaddr.ParseIPPrefix(prefixInConfig)
|
||||
if err != nil {
|
||||
panic(fmt.Errorf("failed to parse ip_prefixes[%d]: %w", i, err))
|
||||
}
|
||||
parsedPrefixes = append(parsedPrefixes, prefix)
|
||||
}
|
||||
|
||||
cfg := headscale.Config{
|
||||
ServerURL: viper.GetString("server_url"),
|
||||
Addr: viper.GetString("listen_addr"),
|
||||
prefixes := make([]netaddr.IPPrefix, 0, len(parsedPrefixes))
|
||||
{
|
||||
// dedup
|
||||
normalizedPrefixes := make(map[string]int, len(parsedPrefixes))
|
||||
for i, p := range parsedPrefixes {
|
||||
normalized, _ := p.Range().Prefix()
|
||||
normalizedPrefixes[normalized.String()] = i
|
||||
}
|
||||
|
||||
// convert back to list
|
||||
for _, i := range normalizedPrefixes {
|
||||
prefixes = append(prefixes, parsedPrefixes[i])
|
||||
}
|
||||
}
|
||||
|
||||
if len(prefixes) < 1 {
|
||||
prefixes = append(prefixes, netaddr.MustParseIPPrefix("100.64.0.0/10"))
|
||||
log.Warn().
|
||||
Msgf("'ip_prefixes' not configured, falling back to default: %v", prefixes)
|
||||
}
|
||||
|
||||
tlsClientAuthMode, _ := headscale.LookupTLSClientAuthMode(
|
||||
viper.GetString("tls_client_auth_mode"),
|
||||
)
|
||||
|
||||
return headscale.Config{
|
||||
ServerURL: viper.GetString("server_url"),
|
||||
Addr: viper.GetString("listen_addr"),
|
||||
MetricsAddr: viper.GetString("metrics_listen_addr"),
|
||||
GRPCAddr: viper.GetString("grpc_listen_addr"),
|
||||
GRPCAllowInsecure: viper.GetBool("grpc_allow_insecure"),
|
||||
|
||||
IPPrefixes: prefixes,
|
||||
PrivateKeyPath: absPath(viper.GetString("private_key_path")),
|
||||
DerpMap: derpMap,
|
||||
BaseDomain: baseDomain,
|
||||
|
||||
EphemeralNodeInactivityTimeout: viper.GetDuration("ephemeral_node_inactivity_timeout"),
|
||||
DERP: derpConfig,
|
||||
|
||||
EphemeralNodeInactivityTimeout: viper.GetDuration(
|
||||
"ephemeral_node_inactivity_timeout",
|
||||
),
|
||||
|
||||
DBtype: viper.GetString("db_type"),
|
||||
DBpath: absPath(viper.GetString("db_path")),
|
||||
@@ -107,71 +344,222 @@ func getHeadscaleApp() (*headscale.Headscale, error) {
|
||||
DBuser: viper.GetString("db_user"),
|
||||
DBpass: viper.GetString("db_pass"),
|
||||
|
||||
TLSLetsEncryptHostname: viper.GetString("tls_letsencrypt_hostname"),
|
||||
TLSLetsEncryptCacheDir: absPath(viper.GetString("tls_letsencrypt_cache_dir")),
|
||||
TLSLetsEncryptHostname: viper.GetString("tls_letsencrypt_hostname"),
|
||||
TLSLetsEncryptListen: viper.GetString("tls_letsencrypt_listen"),
|
||||
TLSLetsEncryptCacheDir: absPath(
|
||||
viper.GetString("tls_letsencrypt_cache_dir"),
|
||||
),
|
||||
TLSLetsEncryptChallengeType: viper.GetString("tls_letsencrypt_challenge_type"),
|
||||
|
||||
TLSCertPath: absPath(viper.GetString("tls_cert_path")),
|
||||
TLSKeyPath: absPath(viper.GetString("tls_key_path")),
|
||||
TLSCertPath: absPath(viper.GetString("tls_cert_path")),
|
||||
TLSKeyPath: absPath(viper.GetString("tls_key_path")),
|
||||
TLSClientAuthMode: tlsClientAuthMode,
|
||||
|
||||
DNSConfig: dnsConfig,
|
||||
|
||||
ACMEEmail: viper.GetString("acme_email"),
|
||||
ACMEURL: viper.GetString("acme_url"),
|
||||
|
||||
UnixSocket: viper.GetString("unix_socket"),
|
||||
UnixSocketPermission: GetFileMode("unix_socket_permission"),
|
||||
|
||||
OIDC: headscale.OIDCConfig{
|
||||
Issuer: viper.GetString("oidc.issuer"),
|
||||
ClientID: viper.GetString("oidc.client_id"),
|
||||
ClientSecret: viper.GetString("oidc.client_secret"),
|
||||
StripEmaildomain: viper.GetBool("oidc.strip_email_domain"),
|
||||
},
|
||||
|
||||
CLI: headscale.CLIConfig{
|
||||
Address: viper.GetString("cli.address"),
|
||||
APIKey: viper.GetString("cli.api_key"),
|
||||
Timeout: viper.GetDuration("cli.timeout"),
|
||||
Insecure: viper.GetBool("cli.insecure"),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func getHeadscaleApp() (*headscale.Headscale, error) {
|
||||
// Minimum inactivity time out is keepalive timeout (60s) plus a few seconds
|
||||
// to avoid races
|
||||
minInactivityTimeout, _ := time.ParseDuration("65s")
|
||||
if viper.GetDuration("ephemeral_node_inactivity_timeout") <= minInactivityTimeout {
|
||||
// TODO: Find a better way to return this text
|
||||
//nolint
|
||||
err := fmt.Errorf(
|
||||
"ephemeral_node_inactivity_timeout (%s) is set too low, must be more than %s",
|
||||
viper.GetString("ephemeral_node_inactivity_timeout"),
|
||||
minInactivityTimeout,
|
||||
)
|
||||
|
||||
return nil, err
|
||||
}
|
||||
|
||||
h, err := headscale.NewHeadscale(cfg)
|
||||
cfg := getHeadscaleConfig()
|
||||
|
||||
app, err := headscale.NewHeadscale(cfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// We are doing this here, as in the future could be cool to have it also hot-reload
|
||||
err = h.LoadACLPolicy(absPath(viper.GetString("acl_policy_path")))
|
||||
if err != nil {
|
||||
log.Printf("Could not load the ACL policy: %s", err)
|
||||
|
||||
if viper.GetString("acl_policy_path") != "" {
|
||||
aclPath := absPath(viper.GetString("acl_policy_path"))
|
||||
err = app.LoadACLPolicy(aclPath)
|
||||
if err != nil {
|
||||
log.Error().
|
||||
Str("path", aclPath).
|
||||
Err(err).
|
||||
Msg("Could not load the ACL policy")
|
||||
}
|
||||
}
|
||||
|
||||
return h, nil
|
||||
return app, nil
|
||||
}
|
||||
|
||||
func loadDerpMap(path string) (*tailcfg.DERPMap, error) {
|
||||
derpFile, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
func getHeadscaleCLIClient() (context.Context, v1.HeadscaleServiceClient, *grpc.ClientConn, context.CancelFunc) {
|
||||
cfg := getHeadscaleConfig()
|
||||
|
||||
log.Debug().
|
||||
Dur("timeout", cfg.CLI.Timeout).
|
||||
Msgf("Setting timeout")
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), cfg.CLI.Timeout)
|
||||
|
||||
grpcOptions := []grpc.DialOption{
|
||||
grpc.WithBlock(),
|
||||
}
|
||||
defer derpFile.Close()
|
||||
var derpMap tailcfg.DERPMap
|
||||
b, err := io.ReadAll(derpFile)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
address := cfg.CLI.Address
|
||||
|
||||
// If the address is not set, we assume that we are on the server hosting headscale.
|
||||
if address == "" {
|
||||
log.Debug().
|
||||
Str("socket", cfg.UnixSocket).
|
||||
Msgf("HEADSCALE_CLI_ADDRESS environment is not set, connecting to unix socket.")
|
||||
|
||||
address = cfg.UnixSocket
|
||||
|
||||
grpcOptions = append(
|
||||
grpcOptions,
|
||||
grpc.WithTransportCredentials(insecure.NewCredentials()),
|
||||
grpc.WithContextDialer(headscale.GrpcSocketDialer),
|
||||
)
|
||||
} else {
|
||||
// If we are not connecting to a local server, require an API key for authentication
|
||||
apiKey := cfg.CLI.APIKey
|
||||
if apiKey == "" {
|
||||
log.Fatal().Caller().Msgf("HEADSCALE_CLI_API_KEY environment variable needs to be set.")
|
||||
}
|
||||
grpcOptions = append(grpcOptions,
|
||||
grpc.WithPerRPCCredentials(tokenAuth{
|
||||
token: apiKey,
|
||||
}),
|
||||
)
|
||||
|
||||
if cfg.CLI.Insecure {
|
||||
tlsConfig := &tls.Config{
|
||||
// turn of gosec as we are intentionally setting
|
||||
// insecure.
|
||||
//nolint:gosec
|
||||
InsecureSkipVerify: true,
|
||||
}
|
||||
|
||||
grpcOptions = append(grpcOptions,
|
||||
grpc.WithTransportCredentials(credentials.NewTLS(tlsConfig)),
|
||||
)
|
||||
} else {
|
||||
grpcOptions = append(grpcOptions,
|
||||
grpc.WithTransportCredentials(credentials.NewClientTLSFromCert(nil, "")),
|
||||
)
|
||||
}
|
||||
}
|
||||
err = yaml.Unmarshal(b, &derpMap)
|
||||
return &derpMap, err
|
||||
|
||||
log.Trace().Caller().Str("address", address).Msg("Connecting via gRPC")
|
||||
conn, err := grpc.DialContext(ctx, address, grpcOptions...)
|
||||
if err != nil {
|
||||
log.Fatal().Caller().Err(err).Msgf("Could not connect: %v", err)
|
||||
}
|
||||
|
||||
client := v1.NewHeadscaleServiceClient(conn)
|
||||
|
||||
return ctx, client, conn, cancel
|
||||
}
|
||||
|
||||
func JsonOutput(result interface{}, errResult error, outputFormat string) {
|
||||
var j []byte
|
||||
func SuccessOutput(result interface{}, override string, outputFormat string) {
|
||||
var jsonBytes []byte
|
||||
var err error
|
||||
switch outputFormat {
|
||||
case "json":
|
||||
if errResult != nil {
|
||||
j, err = json.MarshalIndent(ErrorOutput{errResult.Error()}, "", "\t")
|
||||
if err != nil {
|
||||
log.Fatalln(err)
|
||||
}
|
||||
} else {
|
||||
j, err = json.MarshalIndent(result, "", "\t")
|
||||
if err != nil {
|
||||
log.Fatalln(err)
|
||||
}
|
||||
jsonBytes, err = json.MarshalIndent(result, "", "\t")
|
||||
if err != nil {
|
||||
log.Fatal().Err(err)
|
||||
}
|
||||
case "json-line":
|
||||
if errResult != nil {
|
||||
j, err = json.Marshal(ErrorOutput{errResult.Error()})
|
||||
if err != nil {
|
||||
log.Fatalln(err)
|
||||
}
|
||||
} else {
|
||||
j, err = json.Marshal(result)
|
||||
if err != nil {
|
||||
log.Fatalln(err)
|
||||
}
|
||||
jsonBytes, err = json.Marshal(result)
|
||||
if err != nil {
|
||||
log.Fatal().Err(err)
|
||||
}
|
||||
case "yaml":
|
||||
jsonBytes, err = yaml.Marshal(result)
|
||||
if err != nil {
|
||||
log.Fatal().Err(err)
|
||||
}
|
||||
default:
|
||||
//nolint
|
||||
fmt.Println(override)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
//nolint
|
||||
fmt.Println(string(jsonBytes))
|
||||
}
|
||||
|
||||
func ErrorOutput(errResult error, override string, outputFormat string) {
|
||||
type errOutput struct {
|
||||
Error string `json:"error"`
|
||||
}
|
||||
|
||||
SuccessOutput(errOutput{errResult.Error()}, override, outputFormat)
|
||||
}
|
||||
|
||||
func HasMachineOutputFlag() bool {
|
||||
for _, arg := range os.Args {
|
||||
if arg == "json" || arg == "json-line" || arg == "yaml" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
fmt.Println(string(j))
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
type tokenAuth struct {
|
||||
token string
|
||||
}
|
||||
|
||||
// Return value is mapped to request headers.
|
||||
func (t tokenAuth) GetRequestMetadata(
|
||||
ctx context.Context,
|
||||
in ...string,
|
||||
) (map[string]string, error) {
|
||||
return map[string]string{
|
||||
"authorization": "Bearer " + t.token,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (tokenAuth) RequireTransportSecurity() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func GetFileMode(key string) fs.FileMode {
|
||||
modeStr := viper.GetString(key)
|
||||
|
||||
mode, err := strconv.ParseUint(modeStr, headscale.Base8, headscale.BitSize64)
|
||||
if err != nil {
|
||||
return PermissionFallback
|
||||
}
|
||||
|
||||
return fs.FileMode(mode)
|
||||
}
|
||||
|
||||
21
cmd/headscale/cli/version.go
Normal file
21
cmd/headscale/cli/version.go
Normal file
@@ -0,0 +1,21 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var Version = "dev"
|
||||
|
||||
func init() {
|
||||
rootCmd.AddCommand(versionCmd)
|
||||
}
|
||||
|
||||
var versionCmd = &cobra.Command{
|
||||
Use: "version",
|
||||
Short: "Print the version.",
|
||||
Long: "The version of headscale.",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
output, _ := cmd.Flags().GetString("output")
|
||||
SuccessOutput(map[string]string{"version": Version}, Version, output)
|
||||
},
|
||||
}
|
||||
@@ -2,92 +2,85 @@ package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"strings"
|
||||
"runtime"
|
||||
"time"
|
||||
|
||||
"github.com/efekarakus/termcolor"
|
||||
"github.com/juanfont/headscale/cmd/headscale/cli"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/rs/zerolog/log"
|
||||
"github.com/spf13/viper"
|
||||
"github.com/tcnksm/go-latest"
|
||||
)
|
||||
|
||||
var version = "dev"
|
||||
|
||||
var versionCmd = &cobra.Command{
|
||||
Use: "version",
|
||||
Short: "Print the version.",
|
||||
Long: "The version of headscale.",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
o, _ := cmd.Flags().GetString("output")
|
||||
if strings.HasPrefix(o, "json") {
|
||||
cli.JsonOutput(map[string]string{"version": version}, nil, o)
|
||||
return
|
||||
}
|
||||
fmt.Println(version)
|
||||
},
|
||||
}
|
||||
|
||||
var headscaleCmd = &cobra.Command{
|
||||
Use: "headscale",
|
||||
Short: "headscale - a Tailscale control server",
|
||||
Long: `
|
||||
headscale is an open source implementation of the Tailscale control server
|
||||
|
||||
Juan Font Alonso <juanfontalonso@gmail.com> - 2021
|
||||
https://gitlab.com/juanfont/headscale`,
|
||||
}
|
||||
|
||||
func main() {
|
||||
err := cli.LoadConfig("")
|
||||
var colors bool
|
||||
switch l := termcolor.SupportLevel(os.Stderr); l {
|
||||
case termcolor.Level16M:
|
||||
colors = true
|
||||
case termcolor.Level256:
|
||||
colors = true
|
||||
case termcolor.LevelBasic:
|
||||
colors = true
|
||||
case termcolor.LevelNone:
|
||||
colors = false
|
||||
default:
|
||||
// no color, return text as is.
|
||||
colors = false
|
||||
}
|
||||
|
||||
// Adhere to no-color.org manifesto of allowing users to
|
||||
// turn off color in cli/services
|
||||
if _, noColorIsSet := os.LookupEnv("NO_COLOR"); noColorIsSet {
|
||||
colors = false
|
||||
}
|
||||
|
||||
zerolog.TimeFieldFormat = zerolog.TimeFormatUnix
|
||||
log.Logger = log.Output(zerolog.ConsoleWriter{
|
||||
Out: os.Stdout,
|
||||
TimeFormat: time.RFC3339,
|
||||
NoColor: !colors,
|
||||
})
|
||||
|
||||
if err := cli.LoadConfig(""); err != nil {
|
||||
log.Fatal().Caller().Err(err)
|
||||
}
|
||||
|
||||
machineOutput := cli.HasMachineOutputFlag()
|
||||
|
||||
logLevel := viper.GetString("log_level")
|
||||
level, err := zerolog.ParseLevel(logLevel)
|
||||
if err != nil {
|
||||
log.Fatalf(err.Error())
|
||||
zerolog.SetGlobalLevel(zerolog.DebugLevel)
|
||||
} else {
|
||||
zerolog.SetGlobalLevel(level)
|
||||
}
|
||||
|
||||
headscaleCmd.AddCommand(cli.NamespaceCmd)
|
||||
headscaleCmd.AddCommand(cli.NodeCmd)
|
||||
headscaleCmd.AddCommand(cli.PreauthkeysCmd)
|
||||
headscaleCmd.AddCommand(cli.RoutesCmd)
|
||||
headscaleCmd.AddCommand(cli.ServeCmd)
|
||||
headscaleCmd.AddCommand(versionCmd)
|
||||
|
||||
cli.NodeCmd.PersistentFlags().StringP("namespace", "n", "", "Namespace")
|
||||
err = cli.NodeCmd.MarkPersistentFlagRequired("namespace")
|
||||
if err != nil {
|
||||
log.Fatalf(err.Error())
|
||||
// If the user has requested a "machine" readable format,
|
||||
// then disable login so the output remains valid.
|
||||
if machineOutput {
|
||||
zerolog.SetGlobalLevel(zerolog.Disabled)
|
||||
}
|
||||
|
||||
cli.PreauthkeysCmd.PersistentFlags().StringP("namespace", "n", "", "Namespace")
|
||||
err = cli.PreauthkeysCmd.MarkPersistentFlagRequired("namespace")
|
||||
if err != nil {
|
||||
log.Fatalf(err.Error())
|
||||
if !viper.GetBool("disable_check_updates") && !machineOutput {
|
||||
if (runtime.GOOS == "linux" || runtime.GOOS == "darwin") &&
|
||||
cli.Version != "dev" {
|
||||
githubTag := &latest.GithubTag{
|
||||
Owner: "juanfont",
|
||||
Repository: "headscale",
|
||||
}
|
||||
res, err := latest.Check(githubTag, cli.Version)
|
||||
if err == nil && res.Outdated {
|
||||
//nolint
|
||||
fmt.Printf(
|
||||
"An updated version of Headscale has been found (%s vs. your current %s). Check it out https://github.com/juanfont/headscale/releases\n",
|
||||
res.Current,
|
||||
cli.Version,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cli.RoutesCmd.PersistentFlags().StringP("namespace", "n", "", "Namespace")
|
||||
err = cli.RoutesCmd.MarkPersistentFlagRequired("namespace")
|
||||
if err != nil {
|
||||
log.Fatalf(err.Error())
|
||||
}
|
||||
|
||||
cli.NamespaceCmd.AddCommand(cli.CreateNamespaceCmd)
|
||||
cli.NamespaceCmd.AddCommand(cli.ListNamespacesCmd)
|
||||
cli.NamespaceCmd.AddCommand(cli.DestroyNamespaceCmd)
|
||||
|
||||
cli.NodeCmd.AddCommand(cli.ListNodesCmd)
|
||||
cli.NodeCmd.AddCommand(cli.RegisterCmd)
|
||||
|
||||
cli.RoutesCmd.AddCommand(cli.ListRoutesCmd)
|
||||
cli.RoutesCmd.AddCommand(cli.EnableRouteCmd)
|
||||
|
||||
cli.PreauthkeysCmd.AddCommand(cli.ListPreAuthKeys)
|
||||
cli.PreauthkeysCmd.AddCommand(cli.CreatePreAuthKeyCmd)
|
||||
|
||||
cli.CreatePreAuthKeyCmd.PersistentFlags().Bool("reusable", false, "Make the preauthkey reusable")
|
||||
cli.CreatePreAuthKeyCmd.PersistentFlags().Bool("ephemeral", false, "Preauthkey for ephemeral nodes")
|
||||
cli.CreatePreAuthKeyCmd.Flags().StringP("expiration", "e", "", "Human-readable expiration of the key (30m, 24h, 365d...)")
|
||||
|
||||
headscaleCmd.PersistentFlags().StringP("output", "o", "", "Output format. Empty for human-readable, 'json' or 'json-line'")
|
||||
|
||||
if err := headscaleCmd.Execute(); err != nil {
|
||||
fmt.Println(err)
|
||||
os.Exit(-1)
|
||||
}
|
||||
cli.Execute()
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"io/ioutil"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -25,10 +25,9 @@ func (s *Suite) SetUpSuite(c *check.C) {
|
||||
}
|
||||
|
||||
func (s *Suite) TearDownSuite(c *check.C) {
|
||||
|
||||
}
|
||||
|
||||
func (*Suite) TestPostgresConfigLoading(c *check.C) {
|
||||
func (*Suite) TestConfigLoading(c *check.C) {
|
||||
tmpDir, err := ioutil.TempDir("", "headscale")
|
||||
if err != nil {
|
||||
c.Fatal(err)
|
||||
@@ -41,7 +40,10 @@ func (*Suite) TestPostgresConfigLoading(c *check.C) {
|
||||
}
|
||||
|
||||
// Symlink the example config file
|
||||
err = os.Symlink(filepath.Clean(path+"/../../config.json.postgres.example"), filepath.Join(tmpDir, "config.json"))
|
||||
err = os.Symlink(
|
||||
filepath.Clean(path+"/../../config-example.yaml"),
|
||||
filepath.Join(tmpDir, "config.yaml"),
|
||||
)
|
||||
if err != nil {
|
||||
c.Fatal(err)
|
||||
}
|
||||
@@ -51,51 +53,59 @@ func (*Suite) TestPostgresConfigLoading(c *check.C) {
|
||||
c.Assert(err, check.IsNil)
|
||||
|
||||
// Test that config file was interpreted correctly
|
||||
c.Assert(viper.GetString("server_url"), check.Equals, "http://127.0.0.1:8000")
|
||||
c.Assert(viper.GetString("listen_addr"), check.Equals, "0.0.0.0:8000")
|
||||
c.Assert(viper.GetString("derp_map_path"), check.Equals, "derp.yaml")
|
||||
c.Assert(viper.GetString("db_type"), check.Equals, "postgres")
|
||||
c.Assert(viper.GetString("db_port"), check.Equals, "5432")
|
||||
c.Assert(viper.GetString("tls_letsencrypt_hostname"), check.Equals, "")
|
||||
c.Assert(viper.GetString("tls_letsencrypt_challenge_type"), check.Equals, "HTTP-01")
|
||||
}
|
||||
|
||||
func (*Suite) TestSqliteConfigLoading(c *check.C) {
|
||||
tmpDir, err := ioutil.TempDir("", "headscale")
|
||||
if err != nil {
|
||||
c.Fatal(err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
path, err := os.Getwd()
|
||||
if err != nil {
|
||||
c.Fatal(err)
|
||||
}
|
||||
|
||||
// Symlink the example config file
|
||||
err = os.Symlink(filepath.Clean(path+"/../../config.json.sqlite.example"), filepath.Join(tmpDir, "config.json"))
|
||||
if err != nil {
|
||||
c.Fatal(err)
|
||||
}
|
||||
|
||||
// Load example config, it should load without validation errors
|
||||
err = cli.LoadConfig(tmpDir)
|
||||
c.Assert(err, check.IsNil)
|
||||
|
||||
// Test that config file was interpreted correctly
|
||||
c.Assert(viper.GetString("server_url"), check.Equals, "http://127.0.0.1:8000")
|
||||
c.Assert(viper.GetString("listen_addr"), check.Equals, "0.0.0.0:8000")
|
||||
c.Assert(viper.GetString("derp_map_path"), check.Equals, "derp.yaml")
|
||||
c.Assert(viper.GetString("server_url"), check.Equals, "http://127.0.0.1:8080")
|
||||
c.Assert(viper.GetString("listen_addr"), check.Equals, "0.0.0.0:8080")
|
||||
c.Assert(viper.GetString("metrics_listen_addr"), check.Equals, "127.0.0.1:9090")
|
||||
c.Assert(viper.GetString("db_type"), check.Equals, "sqlite3")
|
||||
c.Assert(viper.GetString("db_path"), check.Equals, "db.sqlite")
|
||||
c.Assert(viper.GetString("db_path"), check.Equals, "/var/lib/headscale/db.sqlite")
|
||||
c.Assert(viper.GetString("tls_letsencrypt_hostname"), check.Equals, "")
|
||||
c.Assert(viper.GetString("tls_letsencrypt_listen"), check.Equals, ":http")
|
||||
c.Assert(viper.GetString("tls_letsencrypt_challenge_type"), check.Equals, "HTTP-01")
|
||||
c.Assert(viper.GetStringSlice("dns_config.nameservers")[0], check.Equals, "1.1.1.1")
|
||||
c.Assert(
|
||||
cli.GetFileMode("unix_socket_permission"),
|
||||
check.Equals,
|
||||
fs.FileMode(0o770),
|
||||
)
|
||||
}
|
||||
|
||||
func (*Suite) TestDNSConfigLoading(c *check.C) {
|
||||
tmpDir, err := ioutil.TempDir("", "headscale")
|
||||
if err != nil {
|
||||
c.Fatal(err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
path, err := os.Getwd()
|
||||
if err != nil {
|
||||
c.Fatal(err)
|
||||
}
|
||||
|
||||
// Symlink the example config file
|
||||
err = os.Symlink(
|
||||
filepath.Clean(path+"/../../config-example.yaml"),
|
||||
filepath.Join(tmpDir, "config.yaml"),
|
||||
)
|
||||
if err != nil {
|
||||
c.Fatal(err)
|
||||
}
|
||||
|
||||
// Load example config, it should load without validation errors
|
||||
err = cli.LoadConfig(tmpDir)
|
||||
c.Assert(err, check.IsNil)
|
||||
|
||||
dnsConfig, baseDomain := cli.GetDNSConfig()
|
||||
|
||||
c.Assert(dnsConfig.Nameservers[0].String(), check.Equals, "1.1.1.1")
|
||||
c.Assert(dnsConfig.Resolvers[0].Addr, check.Equals, "1.1.1.1")
|
||||
c.Assert(dnsConfig.Proxied, check.Equals, true)
|
||||
c.Assert(baseDomain, check.Equals, "example.com")
|
||||
}
|
||||
|
||||
func writeConfig(c *check.C, tmpDir string, configYaml []byte) {
|
||||
// Populate a custom config file
|
||||
configFile := filepath.Join(tmpDir, "config.yaml")
|
||||
err := ioutil.WriteFile(configFile, configYaml, 0644)
|
||||
err := ioutil.WriteFile(configFile, configYaml, 0o600)
|
||||
if err != nil {
|
||||
c.Fatalf("Couldn't write file %s", configFile)
|
||||
}
|
||||
@@ -106,10 +116,11 @@ func (*Suite) TestTLSConfigValidation(c *check.C) {
|
||||
if err != nil {
|
||||
c.Fatal(err)
|
||||
}
|
||||
//defer os.RemoveAll(tmpDir)
|
||||
fmt.Println(tmpDir)
|
||||
// defer os.RemoveAll(tmpDir)
|
||||
|
||||
configYaml := []byte("---\ntls_letsencrypt_hostname: \"example.com\"\ntls_letsencrypt_challenge_type: \"\"\ntls_cert_path: \"abc.pem\"")
|
||||
configYaml := []byte(
|
||||
"---\ntls_letsencrypt_hostname: \"example.com\"\ntls_letsencrypt_challenge_type: \"\"\ntls_cert_path: \"abc.pem\"",
|
||||
)
|
||||
writeConfig(c, tmpDir, configYaml)
|
||||
|
||||
// Check configuration validation errors (1)
|
||||
@@ -117,15 +128,27 @@ func (*Suite) TestTLSConfigValidation(c *check.C) {
|
||||
c.Assert(err, check.NotNil)
|
||||
// check.Matches can not handle multiline strings
|
||||
tmp := strings.ReplaceAll(err.Error(), "\n", "***")
|
||||
c.Assert(tmp, check.Matches, ".*Fatal config error: set either tls_letsencrypt_hostname or tls_cert_path/tls_key_path, not both.*")
|
||||
c.Assert(tmp, check.Matches, ".*Fatal config error: the only supported values for tls_letsencrypt_challenge_type are.*")
|
||||
c.Assert(tmp, check.Matches, ".*Fatal config error: server_url must start with https:// or http://.*")
|
||||
fmt.Println(tmp)
|
||||
c.Assert(
|
||||
tmp,
|
||||
check.Matches,
|
||||
".*Fatal config error: set either tls_letsencrypt_hostname or tls_cert_path/tls_key_path, not both.*",
|
||||
)
|
||||
c.Assert(
|
||||
tmp,
|
||||
check.Matches,
|
||||
".*Fatal config error: the only supported values for tls_letsencrypt_challenge_type are.*",
|
||||
)
|
||||
c.Assert(
|
||||
tmp,
|
||||
check.Matches,
|
||||
".*Fatal config error: server_url must start with https:// or http://.*",
|
||||
)
|
||||
|
||||
// Check configuration validation errors (2)
|
||||
configYaml = []byte("---\nserver_url: \"http://127.0.0.1:8000\"\ntls_letsencrypt_hostname: \"example.com\"\ntls_letsencrypt_challenge_type: \"TLS-ALPN-01\"")
|
||||
configYaml = []byte(
|
||||
"---\nserver_url: \"http://127.0.0.1:8080\"\ntls_letsencrypt_hostname: \"example.com\"\ntls_letsencrypt_challenge_type: \"TLS-ALPN-01\"",
|
||||
)
|
||||
writeConfig(c, tmpDir, configYaml)
|
||||
err = cli.LoadConfig(tmpDir)
|
||||
c.Assert(err, check.NotNil)
|
||||
c.Assert(err, check.ErrorMatches, "Fatal config error: when using tls_letsencrypt_hostname with TLS-ALPN-01 as challenge type, listen_addr must end in :443.*")
|
||||
c.Assert(err, check.IsNil)
|
||||
}
|
||||
|
||||
222
config-example.yaml
Normal file
222
config-example.yaml
Normal file
@@ -0,0 +1,222 @@
|
||||
---
|
||||
# headscale will look for a configuration file named `config.yaml` (or `config.json`) in the following order:
|
||||
#
|
||||
# - `/etc/headscale`
|
||||
# - `~/.headscale`
|
||||
# - current working directory
|
||||
|
||||
# The url clients will connect to.
|
||||
# Typically this will be a domain like:
|
||||
#
|
||||
# https://myheadscale.example.com:443
|
||||
#
|
||||
server_url: http://127.0.0.1:8080
|
||||
|
||||
# Address to listen to / bind to on the server
|
||||
#
|
||||
listen_addr: 0.0.0.0:8080
|
||||
|
||||
# Address to listen to /metrics, you may want
|
||||
# to keep this endpoint private to your internal
|
||||
# network
|
||||
#
|
||||
metrics_listen_addr: 127.0.0.1:9090
|
||||
|
||||
# Address to listen for gRPC.
|
||||
# gRPC is used for controlling a headscale server
|
||||
# remotely with the CLI
|
||||
# Note: Remote access _only_ works if you have
|
||||
# valid certificates.
|
||||
grpc_listen_addr: 0.0.0.0:50443
|
||||
|
||||
# Allow the gRPC admin interface to run in INSECURE
|
||||
# mode. This is not recommended as the traffic will
|
||||
# be unencrypted. Only enable if you know what you
|
||||
# are doing.
|
||||
grpc_allow_insecure: false
|
||||
|
||||
# Private key used encrypt the traffic between headscale
|
||||
# and Tailscale clients.
|
||||
# The private key file which will be
|
||||
# autogenerated if it's missing
|
||||
private_key_path: /var/lib/headscale/private.key
|
||||
|
||||
# List of IP prefixes to allocate tailaddresses from.
|
||||
# Each prefix consists of either an IPv4 or IPv6 address,
|
||||
# and the associated prefix length, delimited by a slash.
|
||||
ip_prefixes:
|
||||
- fd7a:115c:a1e0::/48
|
||||
- 100.64.0.0/10
|
||||
|
||||
# DERP is a relay system that Tailscale uses when a direct
|
||||
# connection cannot be established.
|
||||
# https://tailscale.com/blog/how-tailscale-works/#encrypted-tcp-relays-derp
|
||||
#
|
||||
# headscale needs a list of DERP servers that can be presented
|
||||
# to the clients.
|
||||
derp:
|
||||
server:
|
||||
# If enabled, runs the embedded DERP server and merges it into the rest of the DERP config
|
||||
# The Headscale server_url defined above MUST be using https, DERP requires TLS to be in place
|
||||
enabled: false
|
||||
|
||||
# Region ID to use for the embedded DERP server.
|
||||
# The local DERP prevails if the region ID collides with other region ID coming from
|
||||
# the regular DERP config.
|
||||
region_id: 999
|
||||
|
||||
# Region code and name are displayed in the Tailscale UI to identify a DERP region
|
||||
region_code: "headscale"
|
||||
region_name: "Headscale Embedded DERP"
|
||||
|
||||
# Listens in UDP at the configured address for STUN connections to help on NAT traversal.
|
||||
# When the embedded DERP server is enabled stun_listen_addr MUST be defined.
|
||||
#
|
||||
# For more details on how this works, check this great article: https://tailscale.com/blog/how-tailscale-works/
|
||||
stun_listen_addr: "0.0.0.0:3478"
|
||||
|
||||
# List of externally available DERP maps encoded in JSON
|
||||
urls:
|
||||
- https://controlplane.tailscale.com/derpmap/default
|
||||
|
||||
# Locally available DERP map files encoded in YAML
|
||||
#
|
||||
# This option is mostly interesting for people hosting
|
||||
# their own DERP servers:
|
||||
# https://tailscale.com/kb/1118/custom-derp-servers/
|
||||
#
|
||||
# paths:
|
||||
# - /etc/headscale/derp-example.yaml
|
||||
paths: []
|
||||
|
||||
# If enabled, a worker will be set up to periodically
|
||||
# refresh the given sources and update the derpmap
|
||||
# will be set up.
|
||||
auto_update_enabled: true
|
||||
|
||||
# How often should we check for DERP updates?
|
||||
update_frequency: 24h
|
||||
|
||||
# Disables the automatic check for headscale updates on startup
|
||||
disable_check_updates: false
|
||||
|
||||
# Time before an inactive ephemeral node is deleted?
|
||||
ephemeral_node_inactivity_timeout: 30m
|
||||
|
||||
# SQLite config
|
||||
db_type: sqlite3
|
||||
db_path: /var/lib/headscale/db.sqlite
|
||||
|
||||
# # Postgres config
|
||||
# db_type: postgres
|
||||
# db_host: localhost
|
||||
# db_port: 5432
|
||||
# db_name: headscale
|
||||
# db_user: foo
|
||||
# db_pass: bar
|
||||
|
||||
### TLS configuration
|
||||
#
|
||||
## Let's encrypt / ACME
|
||||
#
|
||||
# headscale supports automatically requesting and setting up
|
||||
# TLS for a domain with Let's Encrypt.
|
||||
#
|
||||
# URL to ACME directory
|
||||
acme_url: https://acme-v02.api.letsencrypt.org/directory
|
||||
|
||||
# Email to register with ACME provider
|
||||
acme_email: ""
|
||||
|
||||
# Domain name to request a TLS certificate for:
|
||||
tls_letsencrypt_hostname: ""
|
||||
|
||||
# Client (Tailscale/Browser) authentication mode (mTLS)
|
||||
# Acceptable values:
|
||||
# - disabled: client authentication disabled
|
||||
# - relaxed: client certificate is required but not verified
|
||||
# - enforced: client certificate is required and verified
|
||||
tls_client_auth_mode: relaxed
|
||||
|
||||
# Path to store certificates and metadata needed by
|
||||
# letsencrypt
|
||||
tls_letsencrypt_cache_dir: /var/lib/headscale/cache
|
||||
|
||||
# Type of ACME challenge to use, currently supported types:
|
||||
# HTTP-01 or TLS-ALPN-01
|
||||
# See [docs/tls.md](docs/tls.md) for more information
|
||||
tls_letsencrypt_challenge_type: HTTP-01
|
||||
# When HTTP-01 challenge is chosen, letsencrypt must set up a
|
||||
# verification endpoint, and it will be listning on:
|
||||
# :http = port 80
|
||||
tls_letsencrypt_listen: ":http"
|
||||
|
||||
## Use already defined certificates:
|
||||
tls_cert_path: ""
|
||||
tls_key_path: ""
|
||||
|
||||
log_level: info
|
||||
|
||||
# Path to a file containg ACL policies.
|
||||
# ACLs can be defined as YAML or HUJSON.
|
||||
# https://tailscale.com/kb/1018/acls/
|
||||
acl_policy_path: ""
|
||||
|
||||
## DNS
|
||||
#
|
||||
# headscale supports Tailscale's DNS configuration and MagicDNS.
|
||||
# Please have a look to their KB to better understand the concepts:
|
||||
#
|
||||
# - https://tailscale.com/kb/1054/dns/
|
||||
# - https://tailscale.com/kb/1081/magicdns/
|
||||
# - https://tailscale.com/blog/2021-09-private-dns-with-magicdns/
|
||||
#
|
||||
dns_config:
|
||||
# List of DNS servers to expose to clients.
|
||||
nameservers:
|
||||
- 1.1.1.1
|
||||
|
||||
# Split DNS (see https://tailscale.com/kb/1054/dns/),
|
||||
# list of search domains and the DNS to query for each one.
|
||||
#
|
||||
# restricted_nameservers:
|
||||
# foo.bar.com:
|
||||
# - 1.1.1.1
|
||||
# darp.headscale.net:
|
||||
# - 1.1.1.1
|
||||
# - 8.8.8.8
|
||||
|
||||
# Search domains to inject.
|
||||
domains: []
|
||||
|
||||
# Whether to use [MagicDNS](https://tailscale.com/kb/1081/magicdns/).
|
||||
# Only works if there is at least a nameserver defined.
|
||||
magic_dns: true
|
||||
|
||||
# Defines the base domain to create the hostnames for MagicDNS.
|
||||
# `base_domain` must be a FQDNs, without the trailing dot.
|
||||
# The FQDN of the hosts will be
|
||||
# `hostname.namespace.base_domain` (e.g., _myhost.mynamespace.example.com_).
|
||||
base_domain: example.com
|
||||
|
||||
# Unix socket used for the CLI to connect without authentication
|
||||
# Note: for local development, you probably want to change this to:
|
||||
# unix_socket: ./headscale.sock
|
||||
unix_socket: /var/run/headscale.sock
|
||||
unix_socket_permission: "0770"
|
||||
#
|
||||
# headscale supports experimental OpenID connect support,
|
||||
# it is still being tested and might have some bugs, please
|
||||
# help us test it.
|
||||
# OpenID Connect
|
||||
# oidc:
|
||||
# issuer: "https://your-oidc.issuer.com/path"
|
||||
# client_id: "your-oidc-client-id"
|
||||
# client_secret: "your-oidc-client-secret"
|
||||
#
|
||||
# If `strip_email_domain` is set to `true`, the domain part of the username email address will be removed.
|
||||
# This will transform `first-name.last-name@example.com` to the namespace `first-name.last-name`
|
||||
# If `strip_email_domain` is set to `false` the domain part will NOT be removed resulting to the following
|
||||
# namespace: `first-name.last-name.example.com`
|
||||
#
|
||||
# strip_email_domain: true
|
||||
@@ -1,19 +0,0 @@
|
||||
{
|
||||
"server_url": "http://127.0.0.1:8000",
|
||||
"listen_addr": "0.0.0.0:8000",
|
||||
"private_key_path": "private.key",
|
||||
"derp_map_path": "derp.yaml",
|
||||
"ephemeral_node_inactivity_timeout": "30m",
|
||||
"db_type": "postgres",
|
||||
"db_host": "localhost",
|
||||
"db_port": 5432,
|
||||
"db_name": "headscale",
|
||||
"db_user": "foo",
|
||||
"db_pass": "bar",
|
||||
"tls_letsencrypt_hostname": "",
|
||||
"tls_letsencrypt_cache_dir": ".cache",
|
||||
"tls_letsencrypt_challenge_type": "HTTP-01",
|
||||
"tls_cert_path": "",
|
||||
"tls_key_path": "",
|
||||
"acl_policy_path": ""
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
{
|
||||
"server_url": "http://127.0.0.1:8000",
|
||||
"listen_addr": "0.0.0.0:8000",
|
||||
"private_key_path": "private.key",
|
||||
"derp_map_path": "derp.yaml",
|
||||
"ephemeral_node_inactivity_timeout": "30m",
|
||||
"db_type": "sqlite3",
|
||||
"db_path": "db.sqlite",
|
||||
"tls_letsencrypt_hostname": "",
|
||||
"tls_letsencrypt_cache_dir": ".cache",
|
||||
"tls_letsencrypt_challenge_type": "HTTP-01",
|
||||
"tls_cert_path": "",
|
||||
"tls_key_path": "",
|
||||
"acl_policy_path": ""
|
||||
}
|
||||
182
db.go
182
db.go
@@ -1,15 +1,25 @@
|
||||
package headscale
|
||||
|
||||
import (
|
||||
"database/sql/driver"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"github.com/rs/zerolog/log"
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
"inet.af/netaddr"
|
||||
"tailscale.com/tailcfg"
|
||||
)
|
||||
|
||||
const dbVersion = "1"
|
||||
const (
|
||||
dbVersion = "1"
|
||||
errValueNotFound = Error("not found")
|
||||
)
|
||||
|
||||
// KV is a key-value store in a psql table. For future use...
|
||||
type KV struct {
|
||||
@@ -24,27 +34,73 @@ func (h *Headscale) initDB() error {
|
||||
}
|
||||
h.db = db
|
||||
|
||||
if h.dbType == "postgres" {
|
||||
db.Exec("create extension if not exists \"uuid-ossp\";")
|
||||
if h.dbType == Postgres {
|
||||
db.Exec(`create extension if not exists "uuid-ossp";`)
|
||||
}
|
||||
|
||||
_ = db.Migrator().RenameColumn(&Machine{}, "ip_address", "ip_addresses")
|
||||
|
||||
// If the Machine table has a column for registered,
|
||||
// find all occourences of "false" and drop them. Then
|
||||
// remove the column.
|
||||
if db.Migrator().HasColumn(&Machine{}, "registered") {
|
||||
log.Info().
|
||||
Msg(`Database has legacy "registered" column in machine, removing...`)
|
||||
|
||||
machines := Machines{}
|
||||
if err := h.db.Not("registered").Find(&machines).Error; err != nil {
|
||||
log.Error().Err(err).Msg("Error accessing db")
|
||||
}
|
||||
|
||||
for _, machine := range machines {
|
||||
log.Info().
|
||||
Str("machine", machine.Name).
|
||||
Str("machine_key", machine.MachineKey).
|
||||
Msg("Deleting unregistered machine")
|
||||
if err := h.db.Delete(&Machine{}, machine.ID).Error; err != nil {
|
||||
log.Error().
|
||||
Err(err).
|
||||
Str("machine", machine.Name).
|
||||
Str("machine_key", machine.MachineKey).
|
||||
Msg("Error deleting unregistered machine")
|
||||
}
|
||||
}
|
||||
|
||||
err := db.Migrator().DropColumn(&Machine{}, "registered")
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("Error dropping registered column")
|
||||
}
|
||||
}
|
||||
|
||||
err = db.AutoMigrate(&Machine{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = db.AutoMigrate(&KV{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = db.AutoMigrate(&Namespace{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = db.AutoMigrate(&PreAuthKey{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_ = db.Migrator().DropTable("shared_machines")
|
||||
|
||||
err = db.AutoMigrate(&APIKey{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = h.setValue("db_version", dbVersion)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -60,12 +116,26 @@ func (h *Headscale) openDB() (*gorm.DB, error) {
|
||||
}
|
||||
|
||||
switch h.dbType {
|
||||
case "sqlite3":
|
||||
db, err = gorm.Open(sqlite.Open(h.dbString), &gorm.Config{
|
||||
DisableForeignKeyConstraintWhenMigrating: true,
|
||||
Logger: log,
|
||||
})
|
||||
case "postgres":
|
||||
case Sqlite:
|
||||
db, err = gorm.Open(
|
||||
sqlite.Open(h.dbString+"?_synchronous=1&_journal_mode=WAL"),
|
||||
&gorm.Config{
|
||||
DisableForeignKeyConstraintWhenMigrating: true,
|
||||
Logger: log,
|
||||
},
|
||||
)
|
||||
|
||||
db.Exec("PRAGMA foreign_keys=ON")
|
||||
|
||||
// The pure Go SQLite library does not handle locking in
|
||||
// the same way as the C based one and we cant use the gorm
|
||||
// connection pool as of 2022/02/23.
|
||||
sqlDB, _ := db.DB()
|
||||
sqlDB.SetMaxIdleConns(1)
|
||||
sqlDB.SetMaxOpenConns(1)
|
||||
sqlDB.SetConnMaxIdleTime(time.Hour)
|
||||
|
||||
case Postgres:
|
||||
db, err = gorm.Open(postgres.Open(h.dbString), &gorm.Config{
|
||||
DisableForeignKeyConstraintWhenMigrating: true,
|
||||
Logger: log,
|
||||
@@ -79,26 +149,104 @@ func (h *Headscale) openDB() (*gorm.DB, error) {
|
||||
return db, nil
|
||||
}
|
||||
|
||||
// getValue returns the value for the given key in KV.
|
||||
func (h *Headscale) getValue(key string) (string, error) {
|
||||
var row KV
|
||||
if result := h.db.First(&row, "key = ?", key); errors.Is(result.Error, gorm.ErrRecordNotFound) {
|
||||
return "", errors.New("not found")
|
||||
if result := h.db.First(&row, "key = ?", key); errors.Is(
|
||||
result.Error,
|
||||
gorm.ErrRecordNotFound,
|
||||
) {
|
||||
return "", errValueNotFound
|
||||
}
|
||||
|
||||
return row.Value, nil
|
||||
}
|
||||
|
||||
// setValue sets value for the given key in KV.
|
||||
func (h *Headscale) setValue(key string, value string) error {
|
||||
kv := KV{
|
||||
keyValue := KV{
|
||||
Key: key,
|
||||
Value: value,
|
||||
}
|
||||
|
||||
_, err := h.getValue(key)
|
||||
if err == nil {
|
||||
h.db.Model(&kv).Where("key = ?", key).Update("value", value)
|
||||
if _, err := h.getValue(key); err == nil {
|
||||
h.db.Model(&keyValue).Where("key = ?", key).Update("value", value)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
h.db.Create(kv)
|
||||
h.db.Create(keyValue)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// This is a "wrapper" type around tailscales
|
||||
// Hostinfo to allow us to add database "serialization"
|
||||
// methods. This allows us to use a typed values throughout
|
||||
// the code and not have to marshal/unmarshal and error
|
||||
// check all over the code.
|
||||
type HostInfo tailcfg.Hostinfo
|
||||
|
||||
func (hi *HostInfo) Scan(destination interface{}) error {
|
||||
switch value := destination.(type) {
|
||||
case []byte:
|
||||
return json.Unmarshal(value, hi)
|
||||
|
||||
case string:
|
||||
return json.Unmarshal([]byte(value), hi)
|
||||
|
||||
default:
|
||||
return fmt.Errorf("%w: unexpected data type %T", errMachineAddressesInvalid, destination)
|
||||
}
|
||||
}
|
||||
|
||||
// Value return json value, implement driver.Valuer interface.
|
||||
func (hi HostInfo) Value() (driver.Value, error) {
|
||||
bytes, err := json.Marshal(hi)
|
||||
|
||||
return string(bytes), err
|
||||
}
|
||||
|
||||
type IPPrefixes []netaddr.IPPrefix
|
||||
|
||||
func (i *IPPrefixes) Scan(destination interface{}) error {
|
||||
switch value := destination.(type) {
|
||||
case []byte:
|
||||
return json.Unmarshal(value, i)
|
||||
|
||||
case string:
|
||||
return json.Unmarshal([]byte(value), i)
|
||||
|
||||
default:
|
||||
return fmt.Errorf("%w: unexpected data type %T", errMachineAddressesInvalid, destination)
|
||||
}
|
||||
}
|
||||
|
||||
// Value return json value, implement driver.Valuer interface.
|
||||
func (i IPPrefixes) Value() (driver.Value, error) {
|
||||
bytes, err := json.Marshal(i)
|
||||
|
||||
return string(bytes), err
|
||||
}
|
||||
|
||||
type StringList []string
|
||||
|
||||
func (i *StringList) Scan(destination interface{}) error {
|
||||
switch value := destination.(type) {
|
||||
case []byte:
|
||||
return json.Unmarshal(value, i)
|
||||
|
||||
case string:
|
||||
return json.Unmarshal([]byte(value), i)
|
||||
|
||||
default:
|
||||
return fmt.Errorf("%w: unexpected data type %T", errMachineAddressesInvalid, destination)
|
||||
}
|
||||
}
|
||||
|
||||
// Value return json value, implement driver.Valuer interface.
|
||||
func (i StringList) Value() (driver.Value, error) {
|
||||
bytes, err := json.Marshal(i)
|
||||
|
||||
return string(bytes), err
|
||||
}
|
||||
|
||||
15
derp-example.yaml
Normal file
15
derp-example.yaml
Normal file
@@ -0,0 +1,15 @@
|
||||
# If you plan to somehow use headscale, please deploy your own DERP infra: https://tailscale.com/kb/1118/custom-derp-servers/
|
||||
regions:
|
||||
900:
|
||||
regionid: 900
|
||||
regioncode: custom
|
||||
regionname: My Region
|
||||
nodes:
|
||||
- name: 900a
|
||||
regionid: 900
|
||||
hostname: myderp.mydomain.no
|
||||
ipv4: 123.123.123.123
|
||||
ipv6: "2604:a880:400:d1::828:b001"
|
||||
stunport: 0
|
||||
stunonly: false
|
||||
derptestport: 0
|
||||
165
derp.go
Normal file
165
derp.go
Normal file
@@ -0,0 +1,165 @@
|
||||
package headscale
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/rs/zerolog/log"
|
||||
"gopkg.in/yaml.v2"
|
||||
"tailscale.com/tailcfg"
|
||||
)
|
||||
|
||||
func loadDERPMapFromPath(path string) (*tailcfg.DERPMap, error) {
|
||||
derpFile, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer derpFile.Close()
|
||||
var derpMap tailcfg.DERPMap
|
||||
b, err := io.ReadAll(derpFile)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
err = yaml.Unmarshal(b, &derpMap)
|
||||
|
||||
return &derpMap, err
|
||||
}
|
||||
|
||||
func loadDERPMapFromURL(addr url.URL) (*tailcfg.DERPMap, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), HTTPReadTimeout)
|
||||
defer cancel()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", addr.String(), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
client := http.Client{
|
||||
Timeout: HTTPReadTimeout,
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
defer resp.Body.Close()
|
||||
body, err := ioutil.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var derpMap tailcfg.DERPMap
|
||||
err = json.Unmarshal(body, &derpMap)
|
||||
|
||||
return &derpMap, err
|
||||
}
|
||||
|
||||
// mergeDERPMaps naively merges a list of DERPMaps into a single
|
||||
// DERPMap, it will _only_ look at the Regions, an integer.
|
||||
// If a region exists in two of the given DERPMaps, the region
|
||||
// form the _last_ DERPMap will be preserved.
|
||||
// An empty DERPMap list will result in a DERPMap with no regions.
|
||||
func mergeDERPMaps(derpMaps []*tailcfg.DERPMap) *tailcfg.DERPMap {
|
||||
result := tailcfg.DERPMap{
|
||||
OmitDefaultRegions: false,
|
||||
Regions: map[int]*tailcfg.DERPRegion{},
|
||||
}
|
||||
|
||||
for _, derpMap := range derpMaps {
|
||||
for id, region := range derpMap.Regions {
|
||||
result.Regions[id] = region
|
||||
}
|
||||
}
|
||||
|
||||
return &result
|
||||
}
|
||||
|
||||
func GetDERPMap(cfg DERPConfig) *tailcfg.DERPMap {
|
||||
derpMaps := make([]*tailcfg.DERPMap, 0)
|
||||
|
||||
for _, path := range cfg.Paths {
|
||||
log.Debug().
|
||||
Str("func", "GetDERPMap").
|
||||
Str("path", path).
|
||||
Msg("Loading DERPMap from path")
|
||||
derpMap, err := loadDERPMapFromPath(path)
|
||||
if err != nil {
|
||||
log.Error().
|
||||
Str("func", "GetDERPMap").
|
||||
Str("path", path).
|
||||
Err(err).
|
||||
Msg("Could not load DERP map from path")
|
||||
|
||||
break
|
||||
}
|
||||
|
||||
derpMaps = append(derpMaps, derpMap)
|
||||
}
|
||||
|
||||
for _, addr := range cfg.URLs {
|
||||
derpMap, err := loadDERPMapFromURL(addr)
|
||||
log.Debug().
|
||||
Str("func", "GetDERPMap").
|
||||
Str("url", addr.String()).
|
||||
Msg("Loading DERPMap from path")
|
||||
if err != nil {
|
||||
log.Error().
|
||||
Str("func", "GetDERPMap").
|
||||
Str("url", addr.String()).
|
||||
Err(err).
|
||||
Msg("Could not load DERP map from path")
|
||||
|
||||
break
|
||||
}
|
||||
|
||||
derpMaps = append(derpMaps, derpMap)
|
||||
}
|
||||
|
||||
derpMap := mergeDERPMaps(derpMaps)
|
||||
|
||||
log.Trace().Interface("derpMap", derpMap).Msg("DERPMap loaded")
|
||||
|
||||
if len(derpMap.Regions) == 0 {
|
||||
log.Warn().
|
||||
Msg("DERP map is empty, not a single DERP map datasource was loaded correctly or contained a region")
|
||||
}
|
||||
|
||||
return derpMap
|
||||
}
|
||||
|
||||
func (h *Headscale) scheduledDERPMapUpdateWorker(cancelChan <-chan struct{}) {
|
||||
log.Info().
|
||||
Dur("frequency", h.cfg.DERP.UpdateFrequency).
|
||||
Msg("Setting up a DERPMap update worker")
|
||||
ticker := time.NewTicker(h.cfg.DERP.UpdateFrequency)
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-cancelChan:
|
||||
return
|
||||
|
||||
case <-ticker.C:
|
||||
log.Info().Msg("Fetching DERPMap updates")
|
||||
h.DERPMap = GetDERPMap(h.cfg.DERP)
|
||||
h.DERPMap.Regions[h.DERPServer.region.RegionID] = &h.DERPServer.region
|
||||
|
||||
namespaces, err := h.ListNamespaces()
|
||||
if err != nil {
|
||||
log.Error().
|
||||
Err(err).
|
||||
Msg("Failed to fetch namespaces")
|
||||
}
|
||||
|
||||
for _, namespace := range namespaces {
|
||||
h.setLastStateChangeToNow(namespace.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
146
derp.yaml
146
derp.yaml
@@ -1,146 +0,0 @@
|
||||
# This file contains some of the official Tailscale DERP servers,
|
||||
# shamelessly taken from https://github.com/tailscale/tailscale/blob/main/net/dnsfallback/dns-fallback-servers.json
|
||||
#
|
||||
# If you plan to somehow use headscale, please deploy your own DERP infra
|
||||
regions:
|
||||
1:
|
||||
regionid: 1
|
||||
regioncode: nyc
|
||||
regionname: New York City
|
||||
nodes:
|
||||
- name: 1a
|
||||
regionid: 1
|
||||
hostname: derp1.tailscale.com
|
||||
ipv4: 159.89.225.99
|
||||
ipv6: "2604:a880:400:d1::828:b001"
|
||||
stunport: 0
|
||||
stunonly: false
|
||||
derptestport: 0
|
||||
- name: 1b
|
||||
regionid: 1
|
||||
hostname: derp1b.tailscale.com
|
||||
ipv4: 45.55.35.93
|
||||
ipv6: "2604:a880:800:a1::f:2001"
|
||||
stunport: 0
|
||||
stunonly: false
|
||||
derptestport: 0
|
||||
2:
|
||||
regionid: 2
|
||||
regioncode: sfo
|
||||
regionname: San Francisco
|
||||
nodes:
|
||||
- name: 2a
|
||||
regionid: 2
|
||||
hostname: derp2.tailscale.com
|
||||
ipv4: 167.172.206.31
|
||||
ipv6: "2604:a880:2:d1::c5:7001"
|
||||
stunport: 0
|
||||
stunonly: false
|
||||
derptestport: 0
|
||||
- name: 2b
|
||||
regionid: 2
|
||||
hostname: derp2b.tailscale.com
|
||||
ipv4: 64.227.106.23
|
||||
ipv6: "2604:a880:4:1d0::29:9000"
|
||||
stunport: 0
|
||||
stunonly: false
|
||||
derptestport: 0
|
||||
3:
|
||||
regionid: 3
|
||||
regioncode: sin
|
||||
regionname: Singapore
|
||||
nodes:
|
||||
- name: 3a
|
||||
regionid: 3
|
||||
hostname: derp3.tailscale.com
|
||||
ipv4: 68.183.179.66
|
||||
ipv6: "2400:6180:0:d1::67d:8001"
|
||||
stunport: 0
|
||||
stunonly: false
|
||||
derptestport: 0
|
||||
4:
|
||||
regionid: 4
|
||||
regioncode: fra
|
||||
regionname: Frankfurt
|
||||
nodes:
|
||||
- name: 4a
|
||||
regionid: 4
|
||||
hostname: derp4.tailscale.com
|
||||
ipv4: 167.172.182.26
|
||||
ipv6: "2a03:b0c0:3:e0::36e:900"
|
||||
stunport: 0
|
||||
stunonly: false
|
||||
derptestport: 0
|
||||
- name: 4b
|
||||
regionid: 4
|
||||
hostname: derp4b.tailscale.com
|
||||
ipv4: 157.230.25.0
|
||||
ipv6: "2a03:b0c0:3:e0::58f:3001"
|
||||
stunport: 0
|
||||
stunonly: false
|
||||
derptestport: 0
|
||||
5:
|
||||
regionid: 5
|
||||
regioncode: syd
|
||||
regionname: Sydney
|
||||
nodes:
|
||||
- name: 5a
|
||||
regionid: 5
|
||||
hostname: derp5.tailscale.com
|
||||
ipv4: 103.43.75.49
|
||||
ipv6: "2001:19f0:5801:10b7:5400:2ff:feaa:284c"
|
||||
stunport: 0
|
||||
stunonly: false
|
||||
derptestport: 0
|
||||
6:
|
||||
regionid: 6
|
||||
regioncode: blr
|
||||
regionname: Bangalore
|
||||
nodes:
|
||||
- name: 6a
|
||||
regionid: 6
|
||||
hostname: derp6.tailscale.com
|
||||
ipv4: 68.183.90.120
|
||||
ipv6: "2400:6180:100:d0::982:d001"
|
||||
stunport: 0
|
||||
stunonly: false
|
||||
derptestport: 0
|
||||
7:
|
||||
regionid: 7
|
||||
regioncode: tok
|
||||
regionname: Tokyo
|
||||
nodes:
|
||||
- name: 7a
|
||||
regionid: 7
|
||||
hostname: derp7.tailscale.com
|
||||
ipv4: 167.179.89.145
|
||||
ipv6: "2401:c080:1000:467f:5400:2ff:feee:22aa"
|
||||
stunport: 0
|
||||
stunonly: false
|
||||
derptestport: 0
|
||||
8:
|
||||
regionid: 8
|
||||
regioncode: lhr
|
||||
regionname: London
|
||||
nodes:
|
||||
- name: 8a
|
||||
regionid: 8
|
||||
hostname: derp8.tailscale.com
|
||||
ipv4: 167.71.139.179
|
||||
ipv6: "2a03:b0c0:1:e0::3cc:e001"
|
||||
stunport: 0
|
||||
stunonly: false
|
||||
derptestport: 0
|
||||
9:
|
||||
regionid: 9
|
||||
regioncode: sao
|
||||
regionname: São Paulo
|
||||
nodes:
|
||||
- name: 9a
|
||||
regionid: 9
|
||||
hostname: derp9.tailscale.com
|
||||
ipv4: 207.148.3.137
|
||||
ipv6: "2001:19f0:6401:1d9c:5400:2ff:feef:bb82"
|
||||
stunport: 0
|
||||
stunonly: false
|
||||
derptestport: 0
|
||||
231
derp_server.go
Normal file
231
derp_server.go
Normal file
@@ -0,0 +1,231 @@
|
||||
package headscale
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/rs/zerolog/log"
|
||||
"tailscale.com/derp"
|
||||
"tailscale.com/net/stun"
|
||||
"tailscale.com/tailcfg"
|
||||
"tailscale.com/types/key"
|
||||
)
|
||||
|
||||
// fastStartHeader is the header (with value "1") that signals to the HTTP
|
||||
// server that the DERP HTTP client does not want the HTTP 101 response
|
||||
// headers and it will begin writing & reading the DERP protocol immediately
|
||||
// following its HTTP request.
|
||||
const fastStartHeader = "Derp-Fast-Start"
|
||||
|
||||
type DERPServer struct {
|
||||
tailscaleDERP *derp.Server
|
||||
region tailcfg.DERPRegion
|
||||
}
|
||||
|
||||
func (h *Headscale) NewDERPServer() (*DERPServer, error) {
|
||||
server := derp.NewServer(key.NodePrivate(*h.privateKey), log.Info().Msgf)
|
||||
region, err := h.generateRegionLocalDERP()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &DERPServer{server, region}, nil
|
||||
}
|
||||
|
||||
func (h *Headscale) generateRegionLocalDERP() (tailcfg.DERPRegion, error) {
|
||||
serverURL, err := url.Parse(h.cfg.ServerURL)
|
||||
if err != nil {
|
||||
return tailcfg.DERPRegion{}, err
|
||||
}
|
||||
var host string
|
||||
var port int
|
||||
host, portStr, err := net.SplitHostPort(serverURL.Host)
|
||||
if err != nil {
|
||||
if serverURL.Scheme == "https" {
|
||||
host = serverURL.Host
|
||||
port = 443
|
||||
} else {
|
||||
host = serverURL.Host
|
||||
port = 80
|
||||
}
|
||||
} else {
|
||||
port, err = strconv.Atoi(portStr)
|
||||
if err != nil {
|
||||
return tailcfg.DERPRegion{}, err
|
||||
}
|
||||
}
|
||||
|
||||
localDERPregion := tailcfg.DERPRegion{
|
||||
RegionID: h.cfg.DERP.ServerRegionID,
|
||||
RegionCode: h.cfg.DERP.ServerRegionCode,
|
||||
RegionName: h.cfg.DERP.ServerRegionName,
|
||||
Avoid: false,
|
||||
Nodes: []*tailcfg.DERPNode{
|
||||
{
|
||||
Name: fmt.Sprintf("%d", h.cfg.DERP.ServerRegionID),
|
||||
RegionID: h.cfg.DERP.ServerRegionID,
|
||||
HostName: host,
|
||||
DERPPort: port,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
_, portSTUNStr, err := net.SplitHostPort(h.cfg.DERP.STUNAddr)
|
||||
if err != nil {
|
||||
return tailcfg.DERPRegion{}, err
|
||||
}
|
||||
portSTUN, err := strconv.Atoi(portSTUNStr)
|
||||
if err != nil {
|
||||
return tailcfg.DERPRegion{}, err
|
||||
}
|
||||
localDERPregion.Nodes[0].STUNPort = portSTUN
|
||||
|
||||
return localDERPregion, nil
|
||||
}
|
||||
|
||||
func (h *Headscale) DERPHandler(ctx *gin.Context) {
|
||||
log.Trace().Caller().Msgf("/derp request from %v", ctx.ClientIP())
|
||||
up := strings.ToLower(ctx.Request.Header.Get("Upgrade"))
|
||||
if up != "websocket" && up != "derp" {
|
||||
if up != "" {
|
||||
log.Warn().Caller().Msgf("Weird websockets connection upgrade: %q", up)
|
||||
}
|
||||
ctx.String(http.StatusUpgradeRequired, "DERP requires connection upgrade")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
fastStart := ctx.Request.Header.Get(fastStartHeader) == "1"
|
||||
|
||||
hijacker, ok := ctx.Writer.(http.Hijacker)
|
||||
if !ok {
|
||||
log.Error().Caller().Msg("DERP requires Hijacker interface from Gin")
|
||||
ctx.String(http.StatusInternalServerError, "HTTP does not support general TCP support")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
netConn, conn, err := hijacker.Hijack()
|
||||
if err != nil {
|
||||
log.Error().Caller().Err(err).Msgf("Hijack failed")
|
||||
ctx.String(http.StatusInternalServerError, "HTTP does not support general TCP support")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if !fastStart {
|
||||
pubKey := h.privateKey.Public()
|
||||
pubKeyStr := pubKey.UntypedHexString() // nolint
|
||||
fmt.Fprintf(conn, "HTTP/1.1 101 Switching Protocols\r\n"+
|
||||
"Upgrade: DERP\r\n"+
|
||||
"Connection: Upgrade\r\n"+
|
||||
"Derp-Version: %v\r\n"+
|
||||
"Derp-Public-Key: %s\r\n\r\n",
|
||||
derp.ProtocolVersion,
|
||||
pubKeyStr)
|
||||
}
|
||||
|
||||
h.DERPServer.tailscaleDERP.Accept(netConn, conn, netConn.RemoteAddr().String())
|
||||
}
|
||||
|
||||
// DERPProbeHandler is the endpoint that js/wasm clients hit to measure
|
||||
// DERP latency, since they can't do UDP STUN queries.
|
||||
func (h *Headscale) DERPProbeHandler(ctx *gin.Context) {
|
||||
switch ctx.Request.Method {
|
||||
case "HEAD", "GET":
|
||||
ctx.Writer.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
default:
|
||||
ctx.String(http.StatusMethodNotAllowed, "bogus probe method")
|
||||
}
|
||||
}
|
||||
|
||||
// DERPBootstrapDNSHandler implements the /bootsrap-dns endpoint
|
||||
// Described in https://github.com/tailscale/tailscale/issues/1405,
|
||||
// this endpoint provides a way to help a client when it fails to start up
|
||||
// because its DNS are broken.
|
||||
// The initial implementation is here https://github.com/tailscale/tailscale/pull/1406
|
||||
// They have a cache, but not clear if that is really necessary at Headscale, uh, scale.
|
||||
// An example implementation is found here https://derp.tailscale.com/bootstrap-dns
|
||||
func (h *Headscale) DERPBootstrapDNSHandler(ctx *gin.Context) {
|
||||
dnsEntries := make(map[string][]net.IP)
|
||||
|
||||
resolvCtx, cancel := context.WithTimeout(context.Background(), time.Minute)
|
||||
defer cancel()
|
||||
var r net.Resolver
|
||||
for _, region := range h.DERPMap.Regions {
|
||||
for _, node := range region.Nodes { // we don't care if we override some nodes
|
||||
addrs, err := r.LookupIP(resolvCtx, "ip", node.HostName)
|
||||
if err != nil {
|
||||
log.Trace().Caller().Err(err).Msgf("bootstrap DNS lookup failed %q", node.HostName)
|
||||
|
||||
continue
|
||||
}
|
||||
dnsEntries[node.HostName] = addrs
|
||||
}
|
||||
}
|
||||
ctx.JSON(http.StatusOK, dnsEntries)
|
||||
}
|
||||
|
||||
// ServeSTUN starts a STUN server on the configured addr.
|
||||
func (h *Headscale) ServeSTUN() {
|
||||
packetConn, err := net.ListenPacket("udp", h.cfg.DERP.STUNAddr)
|
||||
if err != nil {
|
||||
log.Fatal().Msgf("failed to open STUN listener: %v", err)
|
||||
}
|
||||
log.Info().Msgf("STUN server started at %s", packetConn.LocalAddr())
|
||||
|
||||
udpConn, ok := packetConn.(*net.UDPConn)
|
||||
if !ok {
|
||||
log.Fatal().Msg("STUN listener is not a UDP listener")
|
||||
}
|
||||
serverSTUNListener(context.Background(), udpConn)
|
||||
}
|
||||
|
||||
func serverSTUNListener(ctx context.Context, packetConn *net.UDPConn) {
|
||||
var buf [64 << 10]byte
|
||||
var (
|
||||
bytesRead int
|
||||
udpAddr *net.UDPAddr
|
||||
err error
|
||||
)
|
||||
for {
|
||||
bytesRead, udpAddr, err = packetConn.ReadFromUDP(buf[:])
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
log.Error().Caller().Err(err).Msgf("STUN ReadFrom")
|
||||
time.Sleep(time.Second)
|
||||
|
||||
continue
|
||||
}
|
||||
log.Trace().Caller().Msgf("STUN request from %v", udpAddr)
|
||||
pkt := buf[:bytesRead]
|
||||
if !stun.Is(pkt) {
|
||||
log.Trace().Caller().Msgf("UDP packet is not STUN")
|
||||
|
||||
continue
|
||||
}
|
||||
txid, err := stun.ParseBindingRequest(pkt)
|
||||
if err != nil {
|
||||
log.Trace().Caller().Err(err).Msgf("STUN parse error")
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
res := stun.Response(txid, udpAddr.IP, uint16(udpAddr.Port))
|
||||
_, err = packetConn.WriteTo(res, udpAddr)
|
||||
if err != nil {
|
||||
log.Trace().Caller().Err(err).Msgf("Issue writing to UDP")
|
||||
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
193
dns.go
Normal file
193
dns.go
Normal file
@@ -0,0 +1,193 @@
|
||||
package headscale
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/fatih/set"
|
||||
"inet.af/netaddr"
|
||||
"tailscale.com/tailcfg"
|
||||
"tailscale.com/util/dnsname"
|
||||
)
|
||||
|
||||
const (
|
||||
ByteSize = 8
|
||||
)
|
||||
|
||||
const (
|
||||
ipv4AddressLength = 32
|
||||
ipv6AddressLength = 128
|
||||
)
|
||||
|
||||
// generateMagicDNSRootDomains generates a list of DNS entries to be included in `Routes` in `MapResponse`.
|
||||
// This list of reverse DNS entries instructs the OS on what subnets and domains the Tailscale embedded DNS
|
||||
// server (listening in 100.100.100.100 udp/53) should be used for.
|
||||
//
|
||||
// Tailscale.com includes in the list:
|
||||
// - the `BaseDomain` of the user
|
||||
// - the reverse DNS entry for IPv6 (0.e.1.a.c.5.1.1.a.7.d.f.ip6.arpa., see below more on IPv6)
|
||||
// - the reverse DNS entries for the IPv4 subnets covered by the user's `IPPrefix`.
|
||||
// In the public SaaS this is [64-127].100.in-addr.arpa.
|
||||
//
|
||||
// The main purpose of this function is then generating the list of IPv4 entries. For the 100.64.0.0/10, this
|
||||
// is clear, and could be hardcoded. But we are allowing any range as `IPPrefix`, so we need to find out the
|
||||
// subnets when we have 172.16.0.0/16 (i.e., [0-255].16.172.in-addr.arpa.), or any other subnet.
|
||||
//
|
||||
// How IN-ADDR.ARPA domains work is defined in RFC1035 (section 3.5). Tailscale.com seems to adhere to this,
|
||||
// and do not make use of RFC2317 ("Classless IN-ADDR.ARPA delegation") - hence generating the entries for the next
|
||||
// class block only.
|
||||
|
||||
// From the netmask we can find out the wildcard bits (the bits that are not set in the netmask).
|
||||
// This allows us to then calculate the subnets included in the subsequent class block and generate the entries.
|
||||
func generateMagicDNSRootDomains(ipPrefixes []netaddr.IPPrefix) []dnsname.FQDN {
|
||||
fqdns := make([]dnsname.FQDN, 0, len(ipPrefixes))
|
||||
for _, ipPrefix := range ipPrefixes {
|
||||
var generateDNSRoot func(netaddr.IPPrefix) []dnsname.FQDN
|
||||
switch ipPrefix.IP().BitLen() {
|
||||
case ipv4AddressLength:
|
||||
generateDNSRoot = generateIPv4DNSRootDomain
|
||||
|
||||
case ipv6AddressLength:
|
||||
generateDNSRoot = generateIPv6DNSRootDomain
|
||||
|
||||
default:
|
||||
panic(
|
||||
fmt.Sprintf(
|
||||
"unsupported IP version with address length %d",
|
||||
ipPrefix.IP().BitLen(),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
fqdns = append(fqdns, generateDNSRoot(ipPrefix)...)
|
||||
}
|
||||
|
||||
return fqdns
|
||||
}
|
||||
|
||||
func generateIPv4DNSRootDomain(ipPrefix netaddr.IPPrefix) []dnsname.FQDN {
|
||||
// Conversion to the std lib net.IPnet, a bit easier to operate
|
||||
netRange := ipPrefix.IPNet()
|
||||
maskBits, _ := netRange.Mask.Size()
|
||||
|
||||
// lastOctet is the last IP byte covered by the mask
|
||||
lastOctet := maskBits / ByteSize
|
||||
|
||||
// wildcardBits is the number of bits not under the mask in the lastOctet
|
||||
wildcardBits := ByteSize - maskBits%ByteSize
|
||||
|
||||
// min is the value in the lastOctet byte of the IP
|
||||
// max is basically 2^wildcardBits - i.e., the value when all the wildcardBits are set to 1
|
||||
min := uint(netRange.IP[lastOctet])
|
||||
max := (min + 1<<uint(wildcardBits)) - 1
|
||||
|
||||
// here we generate the base domain (e.g., 100.in-addr.arpa., 16.172.in-addr.arpa., etc.)
|
||||
rdnsSlice := []string{}
|
||||
for i := lastOctet - 1; i >= 0; i-- {
|
||||
rdnsSlice = append(rdnsSlice, fmt.Sprintf("%d", netRange.IP[i]))
|
||||
}
|
||||
rdnsSlice = append(rdnsSlice, "in-addr.arpa.")
|
||||
rdnsBase := strings.Join(rdnsSlice, ".")
|
||||
|
||||
fqdns := make([]dnsname.FQDN, 0, max-min+1)
|
||||
for i := min; i <= max; i++ {
|
||||
fqdn, err := dnsname.ToFQDN(fmt.Sprintf("%d.%s", i, rdnsBase))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
fqdns = append(fqdns, fqdn)
|
||||
}
|
||||
|
||||
return fqdns
|
||||
}
|
||||
|
||||
func generateIPv6DNSRootDomain(ipPrefix netaddr.IPPrefix) []dnsname.FQDN {
|
||||
const nibbleLen = 4
|
||||
|
||||
maskBits, _ := ipPrefix.IPNet().Mask.Size()
|
||||
expanded := ipPrefix.IP().StringExpanded()
|
||||
nibbleStr := strings.Map(func(r rune) rune {
|
||||
if r == ':' {
|
||||
return -1
|
||||
}
|
||||
|
||||
return r
|
||||
}, expanded)
|
||||
|
||||
// TODO?: that does not look the most efficient implementation,
|
||||
// but the inputs are not so long as to cause problems,
|
||||
// and from what I can see, the generateMagicDNSRootDomains
|
||||
// function is called only once over the lifetime of a server process.
|
||||
prefixConstantParts := []string{}
|
||||
for i := 0; i < maskBits/nibbleLen; i++ {
|
||||
prefixConstantParts = append(
|
||||
[]string{string(nibbleStr[i])},
|
||||
prefixConstantParts...)
|
||||
}
|
||||
|
||||
makeDomain := func(variablePrefix ...string) (dnsname.FQDN, error) {
|
||||
prefix := strings.Join(append(variablePrefix, prefixConstantParts...), ".")
|
||||
|
||||
return dnsname.ToFQDN(fmt.Sprintf("%s.ip6.arpa", prefix))
|
||||
}
|
||||
|
||||
var fqdns []dnsname.FQDN
|
||||
if maskBits%4 == 0 {
|
||||
dom, _ := makeDomain()
|
||||
fqdns = append(fqdns, dom)
|
||||
} else {
|
||||
domCount := 1 << (maskBits % nibbleLen)
|
||||
fqdns = make([]dnsname.FQDN, 0, domCount)
|
||||
for i := 0; i < domCount; i++ {
|
||||
varNibble := fmt.Sprintf("%x", i)
|
||||
dom, err := makeDomain(varNibble)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
fqdns = append(fqdns, dom)
|
||||
}
|
||||
}
|
||||
|
||||
return fqdns
|
||||
}
|
||||
|
||||
func getMapResponseDNSConfig(
|
||||
dnsConfigOrig *tailcfg.DNSConfig,
|
||||
baseDomain string,
|
||||
machine Machine,
|
||||
peers Machines,
|
||||
) *tailcfg.DNSConfig {
|
||||
var dnsConfig *tailcfg.DNSConfig
|
||||
if dnsConfigOrig != nil && dnsConfigOrig.Proxied { // if MagicDNS is enabled
|
||||
// Only inject the Search Domain of the current namespace - shared nodes should use their full FQDN
|
||||
dnsConfig = dnsConfigOrig.Clone()
|
||||
dnsConfig.Domains = append(
|
||||
dnsConfig.Domains,
|
||||
fmt.Sprintf(
|
||||
"%s.%s",
|
||||
machine.Namespace.Name,
|
||||
baseDomain,
|
||||
),
|
||||
)
|
||||
|
||||
namespaceSet := set.New(set.ThreadSafe)
|
||||
namespaceSet.Add(machine.Namespace)
|
||||
for _, p := range peers {
|
||||
namespaceSet.Add(p.Namespace)
|
||||
}
|
||||
for _, ns := range namespaceSet.List() {
|
||||
namespace, ok := ns.(Namespace)
|
||||
if !ok {
|
||||
dnsConfig = dnsConfigOrig
|
||||
|
||||
continue
|
||||
}
|
||||
dnsRoute := fmt.Sprintf("%v.%v", namespace.Name, baseDomain)
|
||||
dnsConfig.Routes[dnsRoute] = nil
|
||||
}
|
||||
} else {
|
||||
dnsConfig = dnsConfigOrig
|
||||
}
|
||||
|
||||
return dnsConfig
|
||||
}
|
||||
386
dns_test.go
Normal file
386
dns_test.go
Normal file
@@ -0,0 +1,386 @@
|
||||
package headscale
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"gopkg.in/check.v1"
|
||||
"inet.af/netaddr"
|
||||
"tailscale.com/tailcfg"
|
||||
"tailscale.com/types/dnstype"
|
||||
)
|
||||
|
||||
func (s *Suite) TestMagicDNSRootDomains100(c *check.C) {
|
||||
prefixes := []netaddr.IPPrefix{
|
||||
netaddr.MustParseIPPrefix("100.64.0.0/10"),
|
||||
}
|
||||
domains := generateMagicDNSRootDomains(prefixes)
|
||||
|
||||
found := false
|
||||
for _, domain := range domains {
|
||||
if domain == "64.100.in-addr.arpa." {
|
||||
found = true
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
c.Assert(found, check.Equals, true)
|
||||
|
||||
found = false
|
||||
for _, domain := range domains {
|
||||
if domain == "100.100.in-addr.arpa." {
|
||||
found = true
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
c.Assert(found, check.Equals, true)
|
||||
|
||||
found = false
|
||||
for _, domain := range domains {
|
||||
if domain == "127.100.in-addr.arpa." {
|
||||
found = true
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
c.Assert(found, check.Equals, true)
|
||||
}
|
||||
|
||||
func (s *Suite) TestMagicDNSRootDomains172(c *check.C) {
|
||||
prefixes := []netaddr.IPPrefix{
|
||||
netaddr.MustParseIPPrefix("172.16.0.0/16"),
|
||||
}
|
||||
domains := generateMagicDNSRootDomains(prefixes)
|
||||
|
||||
found := false
|
||||
for _, domain := range domains {
|
||||
if domain == "0.16.172.in-addr.arpa." {
|
||||
found = true
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
c.Assert(found, check.Equals, true)
|
||||
|
||||
found = false
|
||||
for _, domain := range domains {
|
||||
if domain == "255.16.172.in-addr.arpa." {
|
||||
found = true
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
c.Assert(found, check.Equals, true)
|
||||
}
|
||||
|
||||
// Happens when netmask is a multiple of 4 bits (sounds likely).
|
||||
func (s *Suite) TestMagicDNSRootDomainsIPv6Single(c *check.C) {
|
||||
prefixes := []netaddr.IPPrefix{
|
||||
netaddr.MustParseIPPrefix("fd7a:115c:a1e0::/48"),
|
||||
}
|
||||
domains := generateMagicDNSRootDomains(prefixes)
|
||||
|
||||
c.Assert(len(domains), check.Equals, 1)
|
||||
c.Assert(
|
||||
domains[0].WithTrailingDot(),
|
||||
check.Equals,
|
||||
"0.e.1.a.c.5.1.1.a.7.d.f.ip6.arpa.",
|
||||
)
|
||||
}
|
||||
|
||||
func (s *Suite) TestMagicDNSRootDomainsIPv6SingleMultiple(c *check.C) {
|
||||
prefixes := []netaddr.IPPrefix{
|
||||
netaddr.MustParseIPPrefix("fd7a:115c:a1e0::/50"),
|
||||
}
|
||||
domains := generateMagicDNSRootDomains(prefixes)
|
||||
|
||||
yieldsRoot := func(dom string) bool {
|
||||
for _, candidate := range domains {
|
||||
if candidate.WithTrailingDot() == dom {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
c.Assert(len(domains), check.Equals, 4)
|
||||
c.Assert(yieldsRoot("0.0.e.1.a.c.5.1.1.a.7.d.f.ip6.arpa."), check.Equals, true)
|
||||
c.Assert(yieldsRoot("1.0.e.1.a.c.5.1.1.a.7.d.f.ip6.arpa."), check.Equals, true)
|
||||
c.Assert(yieldsRoot("2.0.e.1.a.c.5.1.1.a.7.d.f.ip6.arpa."), check.Equals, true)
|
||||
c.Assert(yieldsRoot("3.0.e.1.a.c.5.1.1.a.7.d.f.ip6.arpa."), check.Equals, true)
|
||||
}
|
||||
|
||||
func (s *Suite) TestDNSConfigMapResponseWithMagicDNS(c *check.C) {
|
||||
namespaceShared1, err := app.CreateNamespace("shared1")
|
||||
c.Assert(err, check.IsNil)
|
||||
|
||||
namespaceShared2, err := app.CreateNamespace("shared2")
|
||||
c.Assert(err, check.IsNil)
|
||||
|
||||
namespaceShared3, err := app.CreateNamespace("shared3")
|
||||
c.Assert(err, check.IsNil)
|
||||
|
||||
preAuthKeyInShared1, err := app.CreatePreAuthKey(
|
||||
namespaceShared1.Name,
|
||||
false,
|
||||
false,
|
||||
nil,
|
||||
)
|
||||
c.Assert(err, check.IsNil)
|
||||
|
||||
preAuthKeyInShared2, err := app.CreatePreAuthKey(
|
||||
namespaceShared2.Name,
|
||||
false,
|
||||
false,
|
||||
nil,
|
||||
)
|
||||
c.Assert(err, check.IsNil)
|
||||
|
||||
preAuthKeyInShared3, err := app.CreatePreAuthKey(
|
||||
namespaceShared3.Name,
|
||||
false,
|
||||
false,
|
||||
nil,
|
||||
)
|
||||
c.Assert(err, check.IsNil)
|
||||
|
||||
PreAuthKey2InShared1, err := app.CreatePreAuthKey(
|
||||
namespaceShared1.Name,
|
||||
false,
|
||||
false,
|
||||
nil,
|
||||
)
|
||||
c.Assert(err, check.IsNil)
|
||||
|
||||
_, err = app.GetMachine(namespaceShared1.Name, "test_get_shared_nodes_1")
|
||||
c.Assert(err, check.NotNil)
|
||||
|
||||
machineInShared1 := &Machine{
|
||||
ID: 1,
|
||||
MachineKey: "686824e749f3b7f2a5927ee6c1e422aee5292592d9179a271ed7b3e659b44a66",
|
||||
NodeKey: "686824e749f3b7f2a5927ee6c1e422aee5292592d9179a271ed7b3e659b44a66",
|
||||
DiscoKey: "686824e749f3b7f2a5927ee6c1e422aee5292592d9179a271ed7b3e659b44a66",
|
||||
Name: "test_get_shared_nodes_1",
|
||||
NamespaceID: namespaceShared1.ID,
|
||||
Namespace: *namespaceShared1,
|
||||
RegisterMethod: RegisterMethodAuthKey,
|
||||
IPAddresses: []netaddr.IP{netaddr.MustParseIP("100.64.0.1")},
|
||||
AuthKeyID: uint(preAuthKeyInShared1.ID),
|
||||
}
|
||||
app.db.Save(machineInShared1)
|
||||
|
||||
_, err = app.GetMachine(namespaceShared1.Name, machineInShared1.Name)
|
||||
c.Assert(err, check.IsNil)
|
||||
|
||||
machineInShared2 := &Machine{
|
||||
ID: 2,
|
||||
MachineKey: "dec46ef9dc45c7d2f03bfcd5a640d9e24e3cc68ce3d9da223867c9bc6d5e9863",
|
||||
NodeKey: "dec46ef9dc45c7d2f03bfcd5a640d9e24e3cc68ce3d9da223867c9bc6d5e9863",
|
||||
DiscoKey: "dec46ef9dc45c7d2f03bfcd5a640d9e24e3cc68ce3d9da223867c9bc6d5e9863",
|
||||
Name: "test_get_shared_nodes_2",
|
||||
NamespaceID: namespaceShared2.ID,
|
||||
Namespace: *namespaceShared2,
|
||||
RegisterMethod: RegisterMethodAuthKey,
|
||||
IPAddresses: []netaddr.IP{netaddr.MustParseIP("100.64.0.2")},
|
||||
AuthKeyID: uint(preAuthKeyInShared2.ID),
|
||||
}
|
||||
app.db.Save(machineInShared2)
|
||||
|
||||
_, err = app.GetMachine(namespaceShared2.Name, machineInShared2.Name)
|
||||
c.Assert(err, check.IsNil)
|
||||
|
||||
machineInShared3 := &Machine{
|
||||
ID: 3,
|
||||
MachineKey: "dec46ef9dc45c7d2f03bfcd5a640d9e24e3cc68ce3d9da223867c9bc6d5e9863",
|
||||
NodeKey: "dec46ef9dc45c7d2f03bfcd5a640d9e24e3cc68ce3d9da223867c9bc6d5e9863",
|
||||
DiscoKey: "dec46ef9dc45c7d2f03bfcd5a640d9e24e3cc68ce3d9da223867c9bc6d5e9863",
|
||||
Name: "test_get_shared_nodes_3",
|
||||
NamespaceID: namespaceShared3.ID,
|
||||
Namespace: *namespaceShared3,
|
||||
RegisterMethod: RegisterMethodAuthKey,
|
||||
IPAddresses: []netaddr.IP{netaddr.MustParseIP("100.64.0.3")},
|
||||
AuthKeyID: uint(preAuthKeyInShared3.ID),
|
||||
}
|
||||
app.db.Save(machineInShared3)
|
||||
|
||||
_, err = app.GetMachine(namespaceShared3.Name, machineInShared3.Name)
|
||||
c.Assert(err, check.IsNil)
|
||||
|
||||
machine2InShared1 := &Machine{
|
||||
ID: 4,
|
||||
MachineKey: "dec46ef9dc45c7d2f03bfcd5a640d9e24e3cc68ce3d9da223867c9bc6d5e9863",
|
||||
NodeKey: "dec46ef9dc45c7d2f03bfcd5a640d9e24e3cc68ce3d9da223867c9bc6d5e9863",
|
||||
DiscoKey: "dec46ef9dc45c7d2f03bfcd5a640d9e24e3cc68ce3d9da223867c9bc6d5e9863",
|
||||
Name: "test_get_shared_nodes_4",
|
||||
NamespaceID: namespaceShared1.ID,
|
||||
Namespace: *namespaceShared1,
|
||||
RegisterMethod: RegisterMethodAuthKey,
|
||||
IPAddresses: []netaddr.IP{netaddr.MustParseIP("100.64.0.4")},
|
||||
AuthKeyID: uint(PreAuthKey2InShared1.ID),
|
||||
}
|
||||
app.db.Save(machine2InShared1)
|
||||
|
||||
baseDomain := "foobar.headscale.net"
|
||||
dnsConfigOrig := tailcfg.DNSConfig{
|
||||
Routes: make(map[string][]dnstype.Resolver),
|
||||
Domains: []string{baseDomain},
|
||||
Proxied: true,
|
||||
}
|
||||
|
||||
peersOfMachineInShared1, err := app.getPeers(machineInShared1)
|
||||
c.Assert(err, check.IsNil)
|
||||
|
||||
dnsConfig := getMapResponseDNSConfig(
|
||||
&dnsConfigOrig,
|
||||
baseDomain,
|
||||
*machineInShared1,
|
||||
peersOfMachineInShared1,
|
||||
)
|
||||
c.Assert(dnsConfig, check.NotNil)
|
||||
|
||||
c.Assert(len(dnsConfig.Routes), check.Equals, 3)
|
||||
|
||||
domainRouteShared1 := fmt.Sprintf("%s.%s", namespaceShared1.Name, baseDomain)
|
||||
_, ok := dnsConfig.Routes[domainRouteShared1]
|
||||
c.Assert(ok, check.Equals, true)
|
||||
|
||||
domainRouteShared2 := fmt.Sprintf("%s.%s", namespaceShared2.Name, baseDomain)
|
||||
_, ok = dnsConfig.Routes[domainRouteShared2]
|
||||
c.Assert(ok, check.Equals, true)
|
||||
|
||||
domainRouteShared3 := fmt.Sprintf("%s.%s", namespaceShared3.Name, baseDomain)
|
||||
_, ok = dnsConfig.Routes[domainRouteShared3]
|
||||
c.Assert(ok, check.Equals, true)
|
||||
}
|
||||
|
||||
func (s *Suite) TestDNSConfigMapResponseWithoutMagicDNS(c *check.C) {
|
||||
namespaceShared1, err := app.CreateNamespace("shared1")
|
||||
c.Assert(err, check.IsNil)
|
||||
|
||||
namespaceShared2, err := app.CreateNamespace("shared2")
|
||||
c.Assert(err, check.IsNil)
|
||||
|
||||
namespaceShared3, err := app.CreateNamespace("shared3")
|
||||
c.Assert(err, check.IsNil)
|
||||
|
||||
preAuthKeyInShared1, err := app.CreatePreAuthKey(
|
||||
namespaceShared1.Name,
|
||||
false,
|
||||
false,
|
||||
nil,
|
||||
)
|
||||
c.Assert(err, check.IsNil)
|
||||
|
||||
preAuthKeyInShared2, err := app.CreatePreAuthKey(
|
||||
namespaceShared2.Name,
|
||||
false,
|
||||
false,
|
||||
nil,
|
||||
)
|
||||
c.Assert(err, check.IsNil)
|
||||
|
||||
preAuthKeyInShared3, err := app.CreatePreAuthKey(
|
||||
namespaceShared3.Name,
|
||||
false,
|
||||
false,
|
||||
nil,
|
||||
)
|
||||
c.Assert(err, check.IsNil)
|
||||
|
||||
preAuthKey2InShared1, err := app.CreatePreAuthKey(
|
||||
namespaceShared1.Name,
|
||||
false,
|
||||
false,
|
||||
nil,
|
||||
)
|
||||
c.Assert(err, check.IsNil)
|
||||
|
||||
_, err = app.GetMachine(namespaceShared1.Name, "test_get_shared_nodes_1")
|
||||
c.Assert(err, check.NotNil)
|
||||
|
||||
machineInShared1 := &Machine{
|
||||
ID: 1,
|
||||
MachineKey: "686824e749f3b7f2a5927ee6c1e422aee5292592d9179a271ed7b3e659b44a66",
|
||||
NodeKey: "686824e749f3b7f2a5927ee6c1e422aee5292592d9179a271ed7b3e659b44a66",
|
||||
DiscoKey: "686824e749f3b7f2a5927ee6c1e422aee5292592d9179a271ed7b3e659b44a66",
|
||||
Name: "test_get_shared_nodes_1",
|
||||
NamespaceID: namespaceShared1.ID,
|
||||
Namespace: *namespaceShared1,
|
||||
RegisterMethod: RegisterMethodAuthKey,
|
||||
IPAddresses: []netaddr.IP{netaddr.MustParseIP("100.64.0.1")},
|
||||
AuthKeyID: uint(preAuthKeyInShared1.ID),
|
||||
}
|
||||
app.db.Save(machineInShared1)
|
||||
|
||||
_, err = app.GetMachine(namespaceShared1.Name, machineInShared1.Name)
|
||||
c.Assert(err, check.IsNil)
|
||||
|
||||
machineInShared2 := &Machine{
|
||||
ID: 2,
|
||||
MachineKey: "dec46ef9dc45c7d2f03bfcd5a640d9e24e3cc68ce3d9da223867c9bc6d5e9863",
|
||||
NodeKey: "dec46ef9dc45c7d2f03bfcd5a640d9e24e3cc68ce3d9da223867c9bc6d5e9863",
|
||||
DiscoKey: "dec46ef9dc45c7d2f03bfcd5a640d9e24e3cc68ce3d9da223867c9bc6d5e9863",
|
||||
Name: "test_get_shared_nodes_2",
|
||||
NamespaceID: namespaceShared2.ID,
|
||||
Namespace: *namespaceShared2,
|
||||
RegisterMethod: RegisterMethodAuthKey,
|
||||
IPAddresses: []netaddr.IP{netaddr.MustParseIP("100.64.0.2")},
|
||||
AuthKeyID: uint(preAuthKeyInShared2.ID),
|
||||
}
|
||||
app.db.Save(machineInShared2)
|
||||
|
||||
_, err = app.GetMachine(namespaceShared2.Name, machineInShared2.Name)
|
||||
c.Assert(err, check.IsNil)
|
||||
|
||||
machineInShared3 := &Machine{
|
||||
ID: 3,
|
||||
MachineKey: "dec46ef9dc45c7d2f03bfcd5a640d9e24e3cc68ce3d9da223867c9bc6d5e9863",
|
||||
NodeKey: "dec46ef9dc45c7d2f03bfcd5a640d9e24e3cc68ce3d9da223867c9bc6d5e9863",
|
||||
DiscoKey: "dec46ef9dc45c7d2f03bfcd5a640d9e24e3cc68ce3d9da223867c9bc6d5e9863",
|
||||
Name: "test_get_shared_nodes_3",
|
||||
NamespaceID: namespaceShared3.ID,
|
||||
Namespace: *namespaceShared3,
|
||||
RegisterMethod: RegisterMethodAuthKey,
|
||||
IPAddresses: []netaddr.IP{netaddr.MustParseIP("100.64.0.3")},
|
||||
AuthKeyID: uint(preAuthKeyInShared3.ID),
|
||||
}
|
||||
app.db.Save(machineInShared3)
|
||||
|
||||
_, err = app.GetMachine(namespaceShared3.Name, machineInShared3.Name)
|
||||
c.Assert(err, check.IsNil)
|
||||
|
||||
machine2InShared1 := &Machine{
|
||||
ID: 4,
|
||||
MachineKey: "dec46ef9dc45c7d2f03bfcd5a640d9e24e3cc68ce3d9da223867c9bc6d5e9863",
|
||||
NodeKey: "dec46ef9dc45c7d2f03bfcd5a640d9e24e3cc68ce3d9da223867c9bc6d5e9863",
|
||||
DiscoKey: "dec46ef9dc45c7d2f03bfcd5a640d9e24e3cc68ce3d9da223867c9bc6d5e9863",
|
||||
Name: "test_get_shared_nodes_4",
|
||||
NamespaceID: namespaceShared1.ID,
|
||||
Namespace: *namespaceShared1,
|
||||
RegisterMethod: RegisterMethodAuthKey,
|
||||
IPAddresses: []netaddr.IP{netaddr.MustParseIP("100.64.0.4")},
|
||||
AuthKeyID: uint(preAuthKey2InShared1.ID),
|
||||
}
|
||||
app.db.Save(machine2InShared1)
|
||||
|
||||
baseDomain := "foobar.headscale.net"
|
||||
dnsConfigOrig := tailcfg.DNSConfig{
|
||||
Routes: make(map[string][]dnstype.Resolver),
|
||||
Domains: []string{baseDomain},
|
||||
Proxied: false,
|
||||
}
|
||||
|
||||
peersOfMachine1Shared1, err := app.getPeers(machineInShared1)
|
||||
c.Assert(err, check.IsNil)
|
||||
|
||||
dnsConfig := getMapResponseDNSConfig(
|
||||
&dnsConfigOrig,
|
||||
baseDomain,
|
||||
*machineInShared1,
|
||||
peersOfMachine1Shared1,
|
||||
)
|
||||
c.Assert(dnsConfig, check.NotNil)
|
||||
c.Assert(len(dnsConfig.Routes), check.Equals, 0)
|
||||
c.Assert(len(dnsConfig.Domains), check.Equals, 1)
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
FROM golang:alpine
|
||||
|
||||
# Set necessary environmet variables needed for our image
|
||||
ENV GO111MODULE=on \
|
||||
CGO_ENABLED=0 \
|
||||
GOOS=linux \
|
||||
GOARCH=amd64
|
||||
|
||||
|
||||
|
||||
ENV PATH /usr/lib/postgresql/$PG_MAJOR/bin:$PATH
|
||||
ENV PGDATA /var/lib/postgresql/data
|
||||
ENV POSTGRES_DB headscale
|
||||
ENV POSTGRES_USER admin
|
||||
|
||||
ENV LANG en_US.utf8
|
||||
|
||||
RUN apk update && \
|
||||
apk add git su-exec tzdata libpq postgresql-client postgresql postgresql-contrib gnupg supervisor inotify-tools wireguard-tools openssh && \
|
||||
mkdir /docker-entrypoint-initdb.d && \
|
||||
rm -rf /var/cache/apk/*
|
||||
|
||||
RUN gpg --keyserver ipv4.pool.sks-keyservers.net --recv-keys B42F6819007F00F88E364FD4036A9C25BF357DD4
|
||||
RUN gpg --list-keys --fingerprint --with-colons | sed -E -n -e 's/^fpr:::::::::([0-9A-F]+):$/\1:6:/p' | gpg --import-ownertrust
|
||||
RUN wget -O /usr/local/bin/gosu "https://github.com/tianon/gosu/releases/download/1.7/gosu-amd64" && \
|
||||
wget -O /usr/local/bin/gosu.asc "https://github.com/tianon/gosu/releases/download/1.7/gosu-amd64.asc" && \
|
||||
gpg --verify /usr/local/bin/gosu.asc && \
|
||||
rm /usr/local/bin/gosu.asc && \
|
||||
chmod +x /usr/local/bin/gosu
|
||||
RUN apk --purge del gnupg ca-certificates
|
||||
|
||||
VOLUME /var/lib/postgresql/data
|
||||
|
||||
|
||||
|
||||
|
||||
RUN rm -rf /etc/ssh/ssh_host_rsa_key /etc/ssh/ssh_host_dsa_key
|
||||
|
||||
WORKDIR /build
|
||||
|
||||
RUN git clone https://github.com/juanfont/headscale.git
|
||||
|
||||
WORKDIR /build/headscale
|
||||
|
||||
RUN go build cmd/headscale/headscale.go
|
||||
|
||||
COPY headscale.sh /headscale.sh
|
||||
COPY postgres.sh /postgres.sh
|
||||
COPY supervisord.conf /etc/supervisord.conf
|
||||
|
||||
WORKDIR /
|
||||
|
||||
RUN mkdir -p /run/postgresql
|
||||
RUN chown postgres:postgres /run/postgresql
|
||||
|
||||
RUN adduser -S headscale
|
||||
|
||||
#ENV GIN_MODE release
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
CMD ["supervisord","--nodaemon", "--configuration", "/etc/supervisord.conf"]
|
||||
@@ -1,28 +0,0 @@
|
||||
#!/bin/bash
|
||||
cd /build/headscale
|
||||
echo 'Writing config...'
|
||||
echo '''
|
||||
{
|
||||
"server_url": "$SERVER_URL",
|
||||
"listen_addr": "0.0.0.0:8000",
|
||||
"private_key_path": "private.key",
|
||||
"public_key_path": "public.key",
|
||||
"db_host": "localhost",
|
||||
"db_port": 5432,
|
||||
"db_name": "headscale",
|
||||
"db_user": "admin",
|
||||
"db_pass": "$POSTGRES_PASSWORD"
|
||||
}
|
||||
''' > config.json
|
||||
|
||||
# Wait until PostgreSQL started and listens on port 5432.
|
||||
while [ -z "`netstat -tln | grep 5432`" ]; do
|
||||
echo 'Waiting for PostgreSQL to start ...'
|
||||
sleep 1
|
||||
done
|
||||
echo 'PostgreSQL started.'
|
||||
|
||||
# Start server.
|
||||
echo 'Starting server...'
|
||||
|
||||
./headscale
|
||||
@@ -1,58 +0,0 @@
|
||||
#!/bin/sh
|
||||
chown -R postgres "$PGDATA"
|
||||
if [ -z "$(ls -A "$PGDATA")" ]; then
|
||||
gosu postgres initdb
|
||||
sed -ri "s/^#(listen_addresses\s*=\s*)\S+/\1'*'/" "$PGDATA"/postgresql.conf
|
||||
|
||||
: ${POSTGRES_USER:="postgres"}
|
||||
: ${POSTGRES_DB:=$POSTGRES_USER}
|
||||
|
||||
if [ "$POSTGRES_PASSWORD" ]; then
|
||||
pass="PASSWORD '$POSTGRES_PASSWORD'"
|
||||
authMethod=md5
|
||||
else
|
||||
echo "==============================="
|
||||
echo "!!! NO PASSWORD SET !!! (Use \$POSTGRES_PASSWORD env var)"
|
||||
echo "==============================="
|
||||
pass=
|
||||
authMethod=trust
|
||||
fi
|
||||
echo
|
||||
|
||||
|
||||
if [ "$POSTGRES_DB" != 'postgres' ]; then
|
||||
createSql="CREATE DATABASE $POSTGRES_DB;"
|
||||
echo $createSql | gosu postgres postgres --single -jE
|
||||
echo
|
||||
fi
|
||||
|
||||
if [ "$POSTGRES_USER" != 'postgres' ]; then
|
||||
op=CREATE
|
||||
else
|
||||
op=ALTER
|
||||
fi
|
||||
|
||||
userSql="$op USER $POSTGRES_USER WITH SUPERUSER $pass;"
|
||||
echo $userSql | gosu postgres postgres --single -jE
|
||||
echo
|
||||
|
||||
gosu postgres pg_ctl -D "$PGDATA" \
|
||||
-o "-c listen_addresses=''" \
|
||||
-w start
|
||||
|
||||
echo
|
||||
for f in /docker-entrypoint-initdb.d/*; do
|
||||
case "$f" in
|
||||
*.sh) echo "$0: running $f"; . "$f" ;;
|
||||
*.sql) echo "$0: running $f"; psql --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" < "$f" && echo ;;
|
||||
*) echo "$0: ignoring $f" ;;
|
||||
esac
|
||||
echo
|
||||
done
|
||||
|
||||
gosu postgres pg_ctl -D "$PGDATA" -m fast -w stop
|
||||
|
||||
{ echo; echo "host all all 0.0.0.0/0 $authMethod"; } >> "$PGDATA"/pg_hba.conf
|
||||
fi
|
||||
|
||||
exec gosu postgres postgres
|
||||
@@ -1,4 +0,0 @@
|
||||
# Example of how to user the docker image
|
||||
POSTGRES_PASSWORD=
|
||||
docker build . -t headscale-docker
|
||||
docker run -p 8000:8000 -v $(pwd)/pgdata:/var/lib/postgresql/data -v "$(pwd)/private.key:/build/headscale/private.key" -v "$(pwd)/public.key:/build/headscale/public.key" -e SERVER_URL=127.0.0.1:8000 -e POSTGRES_PASSWORD=$POSTGRES_PASSWORD -ti headscale-docker
|
||||
@@ -1,13 +0,0 @@
|
||||
[supervisord]
|
||||
nodaemon=true
|
||||
user = root
|
||||
|
||||
[program:headscale]
|
||||
command=/bin/bash -c "/headscale.sh"
|
||||
stdout_logfile=/dev/stdout
|
||||
stdout_logfile_maxbytes=0
|
||||
|
||||
[program:postgres]
|
||||
command=/bin/bash -c "/postgres.sh"
|
||||
stdout_logfile=/dev/stdout
|
||||
stdout_logfile_maxbytes=0
|
||||
52
docs/README.md
Normal file
52
docs/README.md
Normal file
@@ -0,0 +1,52 @@
|
||||
# headscale documentation
|
||||
|
||||
This page contains the official and community contributed documentation for `headscale`.
|
||||
|
||||
If you are having trouble with following the documentation or get unexpected results,
|
||||
please ask on [Discord](https://discord.gg/XcQxk2VHjx) instead of opening an Issue.
|
||||
|
||||
## Official documentation
|
||||
|
||||
### How-to
|
||||
|
||||
- [Running headscale on Linux](running-headscale-linux.md)
|
||||
- [Control headscale remotely](remote-cli.md)
|
||||
- [Using a Windows client with headscale](windows-client.md)
|
||||
|
||||
### References
|
||||
|
||||
- [Configuration](../config-example.yaml)
|
||||
- [Glossary](glossary.md)
|
||||
- [TLS](tls.md)
|
||||
|
||||
## Community documentation
|
||||
|
||||
Community documentation is not actively maintained by the headscale authors and is
|
||||
written by community members. It is _not_ verified by `headscale` developers.
|
||||
|
||||
**It might be outdated and it might miss necessary steps**.
|
||||
|
||||
- [Running headscale in a container](running-headscale-container.md)
|
||||
|
||||
## Misc
|
||||
|
||||
### Policy ACLs
|
||||
|
||||
Headscale implements the same policy ACLs as Tailscale.com, adapted to the self-hosted environment.
|
||||
|
||||
For instance, instead of referring to users when defining groups you must
|
||||
use namespaces (which are the equivalent to user/logins in Tailscale.com).
|
||||
|
||||
Please check https://tailscale.com/kb/1018/acls/, and `./tests/acls/` in this repo for working examples.
|
||||
|
||||
When using ACL's the Namespace borders are no longer applied. All machines
|
||||
whichever the Namespace have the ability to communicate with other hosts as
|
||||
long as the ACL's permits this exchange.
|
||||
|
||||
The [ACLs](acls.md) document should help understand a fictional case of setting
|
||||
up ACLs in a small company. All concepts presented in this document could be
|
||||
applied outside of business oriented usage.
|
||||
|
||||
### Apple devices
|
||||
|
||||
An endpoint with information on how to connect your Apple devices (currently macOS only) is available at `/apple` on your running instance.
|
||||
141
docs/acls.md
Normal file
141
docs/acls.md
Normal file
@@ -0,0 +1,141 @@
|
||||
# ACLs use case example
|
||||
|
||||
Let's build an example use case for a small business (It may be the place where
|
||||
ACL's are the most useful).
|
||||
|
||||
We have a small company with a boss, an admin, two developers and an intern.
|
||||
|
||||
The boss should have access to all servers but not to the users hosts. Admin
|
||||
should also have access to all hosts except that their permissions should be
|
||||
limited to maintaining the hosts (for example purposes). The developers can do
|
||||
anything they want on dev hosts, but only watch on productions hosts. Intern
|
||||
can only interact with the development servers.
|
||||
|
||||
Each user have at least a device connected to the network and we have some
|
||||
servers.
|
||||
|
||||
- database.prod
|
||||
- database.dev
|
||||
- app-server1.prod
|
||||
- app-server1.dev
|
||||
- billing.internal
|
||||
|
||||
## Setup of the network
|
||||
|
||||
Let's create the namespaces. Each user should have his own namespace. The users
|
||||
here are represented as namespaces.
|
||||
|
||||
```bash
|
||||
headscale namespaces create boss
|
||||
headscale namespaces create admin1
|
||||
headscale namespaces create dev1
|
||||
headscale namespaces create dev2
|
||||
headscale namespaces create intern1
|
||||
```
|
||||
|
||||
We don't need to create namespaces for the servers because the servers will be
|
||||
tagged. When registering the servers we will need to add the flag
|
||||
`--advertised-tags=tag:<tag1>,tag:<tag2>`, and the user (namespace) that is
|
||||
registering the server should be allowed to do it. Since anyone can add tags to
|
||||
a server they can register, the check of the tags is done on headscale server
|
||||
and only valid tags are applied. A tag is valid if the namespace that is
|
||||
registering it is allowed to do it.
|
||||
|
||||
Here are the ACL's to implement the same permissions as above:
|
||||
|
||||
```json
|
||||
{
|
||||
// groups are collections of users having a common scope. A user can be in multiple groups
|
||||
// groups cannot be composed of groups
|
||||
"groups": {
|
||||
"group:boss": ["boss"],
|
||||
"group:dev": ["dev1", "dev2"],
|
||||
"group:admin": ["admin1"],
|
||||
"group:intern": ["intern1"]
|
||||
},
|
||||
// tagOwners in tailscale is an association between a TAG and the people allowed to set this TAG on a server.
|
||||
// This is documented [here](https://tailscale.com/kb/1068/acl-tags#defining-a-tag)
|
||||
// and explained [here](https://tailscale.com/blog/rbac-like-it-was-meant-to-be/)
|
||||
"tagOwners": {
|
||||
// the administrators can add servers in production
|
||||
"tag:prod-databases": ["group:admin"],
|
||||
"tag:prod-app-servers": ["group:admin"],
|
||||
|
||||
// the boss can tag any server as internal
|
||||
"tag:internal": ["group:boss"],
|
||||
|
||||
// dev can add servers for dev purposes as well as admins
|
||||
"tag:dev-databases": ["group:admin", "group:dev"],
|
||||
"tag:dev-app-servers": ["group:admin", "group:dev"]
|
||||
|
||||
// interns cannot add servers
|
||||
},
|
||||
"acls": [
|
||||
// boss have access to all servers
|
||||
{
|
||||
"action": "accept",
|
||||
"users": ["group:boss"],
|
||||
"ports": [
|
||||
"tag:prod-databases:*",
|
||||
"tag:prod-app-servers:*",
|
||||
"tag:internal:*",
|
||||
"tag:dev-databases:*",
|
||||
"tag:dev-app-servers:*"
|
||||
]
|
||||
},
|
||||
|
||||
// admin have only access to administrative ports of the servers
|
||||
{
|
||||
"action": "accept",
|
||||
"users": ["group:admin"],
|
||||
"ports": [
|
||||
"tag:prod-databases:22",
|
||||
"tag:prod-app-servers:22",
|
||||
"tag:internal:22",
|
||||
"tag:dev-databases:22",
|
||||
"tag:dev-app-servers:22"
|
||||
]
|
||||
},
|
||||
|
||||
// developers have access to databases servers and application servers on all ports
|
||||
// they can only view the applications servers in prod and have no access to databases servers in production
|
||||
{
|
||||
"action": "accept",
|
||||
"users": ["group:dev"],
|
||||
"ports": [
|
||||
"tag:dev-databases:*",
|
||||
"tag:dev-app-servers:*",
|
||||
"tag:prod-app-servers:80,443"
|
||||
]
|
||||
},
|
||||
|
||||
// servers should be able to talk to database. Database should not be able to initiate connections to
|
||||
// applications servers
|
||||
{
|
||||
"action": "accept",
|
||||
"users": ["tag:dev-app-servers"],
|
||||
"ports": ["tag:dev-databases:5432"]
|
||||
},
|
||||
{
|
||||
"action": "accept",
|
||||
"users": ["tag:prod-app-servers"],
|
||||
"ports": ["tag:prod-databases:5432"]
|
||||
},
|
||||
|
||||
// interns have access to dev-app-servers only in reading mode
|
||||
{
|
||||
"action": "accept",
|
||||
"users": ["group:intern"],
|
||||
"ports": ["tag:dev-app-servers:80,443"]
|
||||
},
|
||||
|
||||
// We still have to allow internal namespaces communications since nothing guarantees that each user have
|
||||
// their own namespaces.
|
||||
{ "action": "accept", "users": ["boss"], "ports": ["boss:*"] },
|
||||
{ "action": "accept", "users": ["dev1"], "ports": ["dev1:*"] },
|
||||
{ "action": "accept", "users": ["dev2"], "ports": ["dev2:*"] },
|
||||
{ "action": "accept", "users": ["admin1"], "ports": ["admin1:*"] },
|
||||
{ "action": "accept", "users": ["intern1"], "ports": ["intern1:*"] }
|
||||
]
|
||||
}
|
||||
```
|
||||
5
docs/examples/README.md
Normal file
5
docs/examples/README.md
Normal file
@@ -0,0 +1,5 @@
|
||||
# Examples
|
||||
|
||||
This directory contains examples on how to run `headscale` on different platforms.
|
||||
|
||||
All examples are provided by the community and they are not verified by the `headscale` authors.
|
||||
@@ -1,7 +1,9 @@
|
||||
# Deploying Headscale on Kubernetes
|
||||
# Deploying headscale on Kubernetes
|
||||
|
||||
**Note:** This is contributed by the community and not verified by the headscale authors.
|
||||
|
||||
This directory contains [Kustomize](https://kustomize.io) templates that deploy
|
||||
Headscale in various configurations.
|
||||
headscale in various configurations.
|
||||
|
||||
These templates currently support Rancher k3s. Other clusters may require
|
||||
adaptation, especially around volume claims and ingress.
|
||||
@@ -24,6 +26,7 @@ Configure DERP servers by editing `base/site/derp.yaml` if needed.
|
||||
You'll somehow need to get `headscale:latest` into your cluster image registry.
|
||||
|
||||
An easy way to do this with k3s:
|
||||
|
||||
- Reconfigure k3s to use docker instead of containerd (`k3s server --docker`)
|
||||
- `docker build -t headscale:latest ..` from here
|
||||
|
||||
@@ -61,22 +64,21 @@ Use the wrapper script to remotely operate headscale to perform administrative
|
||||
tasks like creating namespaces, authkeys, etc.
|
||||
|
||||
```
|
||||
[c@nix-slate:~/Projects/headscale/k8s]$ ./headscale.bash
|
||||
[c@nix-slate:~/Projects/headscale/k8s]$ ./headscale.bash
|
||||
|
||||
headscale is an open source implementation of the Tailscale control server
|
||||
|
||||
Juan Font Alonso <juanfontalonso@gmail.com> - 2021
|
||||
https://gitlab.com/juanfont/headscale
|
||||
https://github.com/juanfont/headscale
|
||||
|
||||
Usage:
|
||||
headscale [command]
|
||||
|
||||
Available Commands:
|
||||
help Help about any command
|
||||
namespace Manage the namespaces of Headscale
|
||||
node Manage the nodes of Headscale
|
||||
preauthkey Handle the preauthkeys in Headscale
|
||||
routes Manage the routes of Headscale
|
||||
namespace Manage the namespaces of headscale
|
||||
node Manage the nodes of headscale
|
||||
preauthkey Handle the preauthkeys in headscale
|
||||
routes Manage the routes of headscale
|
||||
serve Launches the headscale server
|
||||
version Print the version.
|
||||
|
||||
@@ -90,7 +92,6 @@ Use "headscale [command] --help" for more information about a command.
|
||||
|
||||
# TODO / Ideas
|
||||
|
||||
- Github action to publish the docker image
|
||||
- Interpolate `email:` option to the ClusterIssuer from site configuration.
|
||||
This probably needs to be done with a transformer, kustomize vars don't seem to work.
|
||||
- Add kustomize examples for cloud-native ingress, load balancer
|
||||
@@ -5,4 +5,5 @@ metadata:
|
||||
data:
|
||||
server_url: $(PUBLIC_PROTO)://$(PUBLIC_HOSTNAME)
|
||||
listen_addr: "0.0.0.0:8080"
|
||||
metrics_listen_addr: "127.0.0.1:9090"
|
||||
ephemeral_node_inactivity_timeout: "30m"
|
||||
18
docs/examples/kustomize/base/ingress.yaml
Normal file
18
docs/examples/kustomize/base/ingress.yaml
Normal file
@@ -0,0 +1,18 @@
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: headscale
|
||||
annotations:
|
||||
kubernetes.io/ingress.class: traefik
|
||||
spec:
|
||||
rules:
|
||||
- host: $(PUBLIC_HOSTNAME)
|
||||
http:
|
||||
paths:
|
||||
- backend:
|
||||
service:
|
||||
name: headscale
|
||||
port:
|
||||
number: 8080
|
||||
path: /
|
||||
pathType: Prefix
|
||||
42
docs/examples/kustomize/base/kustomization.yaml
Normal file
42
docs/examples/kustomize/base/kustomization.yaml
Normal file
@@ -0,0 +1,42 @@
|
||||
namespace: headscale
|
||||
resources:
|
||||
- configmap.yaml
|
||||
- ingress.yaml
|
||||
- service.yaml
|
||||
generatorOptions:
|
||||
disableNameSuffixHash: true
|
||||
configMapGenerator:
|
||||
- name: headscale-site
|
||||
files:
|
||||
- derp.yaml=site/derp.yaml
|
||||
envs:
|
||||
- site/public.env
|
||||
- name: headscale-etc
|
||||
literals:
|
||||
- config.json={}
|
||||
secretGenerator:
|
||||
- name: headscale
|
||||
files:
|
||||
- secrets/private-key
|
||||
vars:
|
||||
- name: PUBLIC_PROTO
|
||||
objRef:
|
||||
kind: ConfigMap
|
||||
name: headscale-site
|
||||
apiVersion: v1
|
||||
fieldRef:
|
||||
fieldPath: data.public-proto
|
||||
- name: PUBLIC_HOSTNAME
|
||||
objRef:
|
||||
kind: ConfigMap
|
||||
name: headscale-site
|
||||
apiVersion: v1
|
||||
fieldRef:
|
||||
fieldPath: data.public-hostname
|
||||
- name: CONTACT_EMAIL
|
||||
objRef:
|
||||
kind: ConfigMap
|
||||
name: headscale-site
|
||||
apiVersion: v1
|
||||
fieldRef:
|
||||
fieldPath: data.contact-email
|
||||
@@ -8,6 +8,6 @@ spec:
|
||||
selector:
|
||||
app: headscale
|
||||
ports:
|
||||
- name: http
|
||||
targetPort: http
|
||||
port: 8080
|
||||
- name: http
|
||||
targetPort: http
|
||||
port: 8080
|
||||
81
docs/examples/kustomize/postgres/deployment.yaml
Normal file
81
docs/examples/kustomize/postgres/deployment.yaml
Normal file
@@ -0,0 +1,81 @@
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: headscale
|
||||
spec:
|
||||
replicas: 2
|
||||
selector:
|
||||
matchLabels:
|
||||
app: headscale
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: headscale
|
||||
spec:
|
||||
containers:
|
||||
- name: headscale
|
||||
image: "headscale:latest"
|
||||
imagePullPolicy: IfNotPresent
|
||||
command: ["/go/bin/headscale", "serve"]
|
||||
env:
|
||||
- name: SERVER_URL
|
||||
value: $(PUBLIC_PROTO)://$(PUBLIC_HOSTNAME)
|
||||
- name: LISTEN_ADDR
|
||||
valueFrom:
|
||||
configMapKeyRef:
|
||||
name: headscale-config
|
||||
key: listen_addr
|
||||
- name: METRICS_LISTEN_ADDR
|
||||
valueFrom:
|
||||
configMapKeyRef:
|
||||
name: headscale-config
|
||||
key: metrics_listen_addr
|
||||
- name: DERP_MAP_PATH
|
||||
value: /vol/config/derp.yaml
|
||||
- name: EPHEMERAL_NODE_INACTIVITY_TIMEOUT
|
||||
valueFrom:
|
||||
configMapKeyRef:
|
||||
name: headscale-config
|
||||
key: ephemeral_node_inactivity_timeout
|
||||
- name: DB_TYPE
|
||||
value: postgres
|
||||
- name: DB_HOST
|
||||
value: postgres.headscale.svc.cluster.local
|
||||
- name: DB_PORT
|
||||
value: "5432"
|
||||
- name: DB_USER
|
||||
value: headscale
|
||||
- name: DB_PASS
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: postgresql
|
||||
key: password
|
||||
- name: DB_NAME
|
||||
value: headscale
|
||||
ports:
|
||||
- name: http
|
||||
protocol: TCP
|
||||
containerPort: 8080
|
||||
livenessProbe:
|
||||
tcpSocket:
|
||||
port: http
|
||||
initialDelaySeconds: 30
|
||||
timeoutSeconds: 5
|
||||
periodSeconds: 15
|
||||
volumeMounts:
|
||||
- name: config
|
||||
mountPath: /vol/config
|
||||
- name: secret
|
||||
mountPath: /vol/secret
|
||||
- name: etc
|
||||
mountPath: /etc/headscale
|
||||
volumes:
|
||||
- name: config
|
||||
configMap:
|
||||
name: headscale-site
|
||||
- name: etc
|
||||
configMap:
|
||||
name: headscale-etc
|
||||
- name: secret
|
||||
secret:
|
||||
secretName: headscale
|
||||
13
docs/examples/kustomize/postgres/kustomization.yaml
Normal file
13
docs/examples/kustomize/postgres/kustomization.yaml
Normal file
@@ -0,0 +1,13 @@
|
||||
namespace: headscale
|
||||
bases:
|
||||
- ../base
|
||||
resources:
|
||||
- deployment.yaml
|
||||
- postgres-service.yaml
|
||||
- postgres-statefulset.yaml
|
||||
generatorOptions:
|
||||
disableNameSuffixHash: true
|
||||
secretGenerator:
|
||||
- name: postgresql
|
||||
files:
|
||||
- secrets/password
|
||||
@@ -8,6 +8,6 @@ spec:
|
||||
selector:
|
||||
app: postgres
|
||||
ports:
|
||||
- name: postgres
|
||||
targetPort: postgres
|
||||
port: 5432
|
||||
- name: postgres
|
||||
targetPort: postgres
|
||||
port: 5432
|
||||
49
docs/examples/kustomize/postgres/postgres-statefulset.yaml
Normal file
49
docs/examples/kustomize/postgres/postgres-statefulset.yaml
Normal file
@@ -0,0 +1,49 @@
|
||||
apiVersion: apps/v1
|
||||
kind: StatefulSet
|
||||
metadata:
|
||||
name: postgres
|
||||
spec:
|
||||
serviceName: postgres
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: postgres
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: postgres
|
||||
spec:
|
||||
containers:
|
||||
- name: postgres
|
||||
image: "postgres:13"
|
||||
imagePullPolicy: IfNotPresent
|
||||
env:
|
||||
- name: POSTGRES_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: postgresql
|
||||
key: password
|
||||
- name: POSTGRES_USER
|
||||
value: headscale
|
||||
ports:
|
||||
- name: postgres
|
||||
protocol: TCP
|
||||
containerPort: 5432
|
||||
livenessProbe:
|
||||
tcpSocket:
|
||||
port: 5432
|
||||
initialDelaySeconds: 30
|
||||
timeoutSeconds: 5
|
||||
periodSeconds: 15
|
||||
volumeMounts:
|
||||
- name: pgdata
|
||||
mountPath: /var/lib/postgresql/data
|
||||
volumeClaimTemplates:
|
||||
- metadata:
|
||||
name: pgdata
|
||||
spec:
|
||||
storageClassName: local-path
|
||||
accessModes: ["ReadWriteOnce"]
|
||||
resources:
|
||||
requests:
|
||||
storage: 1Gi
|
||||
@@ -6,6 +6,6 @@ metadata:
|
||||
traefik.ingress.kubernetes.io/router.tls: "true"
|
||||
spec:
|
||||
tls:
|
||||
- hosts:
|
||||
- $(PUBLIC_HOSTNAME)
|
||||
secretName: production-cert
|
||||
- hosts:
|
||||
- $(PUBLIC_HOSTNAME)
|
||||
secretName: production-cert
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace: headscale
|
||||
bases:
|
||||
- ../base
|
||||
resources:
|
||||
- production-issuer.yaml
|
||||
patches:
|
||||
- path: ingress-patch.yaml
|
||||
target:
|
||||
kind: Ingress
|
||||
@@ -11,6 +11,6 @@ spec:
|
||||
# Secret resource used to store the account's private key.
|
||||
name: letsencrypt-production-acc-key
|
||||
solvers:
|
||||
- http01:
|
||||
ingress:
|
||||
class: traefik
|
||||
- http01:
|
||||
ingress:
|
||||
class: traefik
|
||||
@@ -1,5 +1,5 @@
|
||||
namespace: headscale
|
||||
bases:
|
||||
- ../base
|
||||
- ../base
|
||||
resources:
|
||||
- statefulset.yaml
|
||||
- statefulset.yaml
|
||||
82
docs/examples/kustomize/sqlite/statefulset.yaml
Normal file
82
docs/examples/kustomize/sqlite/statefulset.yaml
Normal file
@@ -0,0 +1,82 @@
|
||||
apiVersion: apps/v1
|
||||
kind: StatefulSet
|
||||
metadata:
|
||||
name: headscale
|
||||
spec:
|
||||
serviceName: headscale
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: headscale
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: headscale
|
||||
spec:
|
||||
containers:
|
||||
- name: headscale
|
||||
image: "headscale:latest"
|
||||
imagePullPolicy: IfNotPresent
|
||||
command: ["/go/bin/headscale", "serve"]
|
||||
env:
|
||||
- name: SERVER_URL
|
||||
value: $(PUBLIC_PROTO)://$(PUBLIC_HOSTNAME)
|
||||
- name: LISTEN_ADDR
|
||||
valueFrom:
|
||||
configMapKeyRef:
|
||||
name: headscale-config
|
||||
key: listen_addr
|
||||
- name: METRICS_LISTEN_ADDR
|
||||
valueFrom:
|
||||
configMapKeyRef:
|
||||
name: headscale-config
|
||||
key: metrics_listen_addr
|
||||
- name: DERP_MAP_PATH
|
||||
value: /vol/config/derp.yaml
|
||||
- name: EPHEMERAL_NODE_INACTIVITY_TIMEOUT
|
||||
valueFrom:
|
||||
configMapKeyRef:
|
||||
name: headscale-config
|
||||
key: ephemeral_node_inactivity_timeout
|
||||
- name: DB_TYPE
|
||||
value: sqlite3
|
||||
- name: DB_PATH
|
||||
value: /vol/data/db.sqlite
|
||||
ports:
|
||||
- name: http
|
||||
protocol: TCP
|
||||
containerPort: 8080
|
||||
livenessProbe:
|
||||
tcpSocket:
|
||||
port: http
|
||||
initialDelaySeconds: 30
|
||||
timeoutSeconds: 5
|
||||
periodSeconds: 15
|
||||
volumeMounts:
|
||||
- name: config
|
||||
mountPath: /vol/config
|
||||
- name: data
|
||||
mountPath: /vol/data
|
||||
- name: secret
|
||||
mountPath: /vol/secret
|
||||
- name: etc
|
||||
mountPath: /etc/headscale
|
||||
volumes:
|
||||
- name: config
|
||||
configMap:
|
||||
name: headscale-site
|
||||
- name: etc
|
||||
configMap:
|
||||
name: headscale-etc
|
||||
- name: secret
|
||||
secret:
|
||||
secretName: headscale
|
||||
volumeClaimTemplates:
|
||||
- metadata:
|
||||
name: data
|
||||
spec:
|
||||
storageClassName: local-path
|
||||
accessModes: ["ReadWriteOnce"]
|
||||
resources:
|
||||
requests:
|
||||
storage: 1Gi
|
||||
@@ -6,6 +6,6 @@ metadata:
|
||||
traefik.ingress.kubernetes.io/router.tls: "true"
|
||||
spec:
|
||||
tls:
|
||||
- hosts:
|
||||
- $(PUBLIC_HOSTNAME)
|
||||
secretName: staging-cert
|
||||
- hosts:
|
||||
- $(PUBLIC_HOSTNAME)
|
||||
secretName: staging-cert
|
||||
9
docs/examples/kustomize/staging-tls/kustomization.yaml
Normal file
9
docs/examples/kustomize/staging-tls/kustomization.yaml
Normal file
@@ -0,0 +1,9 @@
|
||||
namespace: headscale
|
||||
bases:
|
||||
- ../base
|
||||
resources:
|
||||
- staging-issuer.yaml
|
||||
patches:
|
||||
- path: ingress-patch.yaml
|
||||
target:
|
||||
kind: Ingress
|
||||
@@ -11,6 +11,6 @@ spec:
|
||||
# Secret resource used to store the account's private key.
|
||||
name: letsencrypt-staging-acc-key
|
||||
solvers:
|
||||
- http01:
|
||||
ingress:
|
||||
class: traefik
|
||||
- http01:
|
||||
ingress:
|
||||
class: traefik
|
||||
6
docs/glossary.md
Normal file
6
docs/glossary.md
Normal file
@@ -0,0 +1,6 @@
|
||||
# Glossary
|
||||
|
||||
| Term | Description |
|
||||
| --------- | --------------------------------------------------------------------------------------------------------------------- |
|
||||
| Machine | A machine is a single entity connected to `headscale`, typically an installation of Tailscale. Also known as **Node** |
|
||||
| Namespace | A namespace is a logical grouping of machines "owned" by the same entity, in Tailscale, this is typically a User |
|
||||
BIN
docs/images/windows-registry.png
Normal file
BIN
docs/images/windows-registry.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 101 KiB |
362
docs/proposals/001-acls.md
Normal file
362
docs/proposals/001-acls.md
Normal file
@@ -0,0 +1,362 @@
|
||||
# ACLs
|
||||
|
||||
A key component of tailscale is the notion of Tailnet. This notion is hidden
|
||||
but the implications that it have on how to use tailscale are not.
|
||||
|
||||
For tailscale an [tailnet](https://tailscale.com/kb/1136/tailnet/) is the
|
||||
following:
|
||||
|
||||
> For personal users, you are a tailnet of many devices and one person. Each
|
||||
> device gets a private Tailscale IP address in the CGNAT range and every
|
||||
> device can talk directly to every other device, wherever they are on the
|
||||
> internet.
|
||||
>
|
||||
> For businesses and organizations, a tailnet is many devices and many users.
|
||||
> It can be based on your Microsoft Active Directory, your Google Workspace, a
|
||||
> GitHub organization, Okta tenancy, or other identity provider namespace. All
|
||||
> of the devices and users in your tailnet can be seen by the tailnet
|
||||
> administrators in the Tailscale admin console. There you can apply
|
||||
> tailnet-wide configuration, such as ACLs that affect visibility of devices
|
||||
> inside your tailnet, DNS settings, and more.
|
||||
|
||||
## Current implementation and issues
|
||||
|
||||
Currently in headscale, the namespaces are used both as tailnet and users. The
|
||||
issue is that if we want to use the ACL's we can't use both at the same time.
|
||||
|
||||
Tailnet's cannot communicate with each others. So we can't have an ACL that
|
||||
authorize tailnet (namespace) A to talk to tailnet (namespace) B.
|
||||
|
||||
We also can't write ACLs based on the users (namespaces in headscale) since all
|
||||
devices belong to the same user.
|
||||
|
||||
With the current implementation the only ACL that we can user is to associate
|
||||
each headscale IP to a host manually then write the ACLs according to this
|
||||
manual mapping.
|
||||
|
||||
```json
|
||||
{
|
||||
"hosts": {
|
||||
"host1": "100.64.0.1",
|
||||
"server": "100.64.0.2"
|
||||
},
|
||||
"acls": [
|
||||
{ "action": "accept", "users": ["host1"], "ports": ["host2:80,443"] }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
While this works, it requires a lot of manual editing on the configuration and
|
||||
to keep track of all devices IP address.
|
||||
|
||||
## Proposition for a next implementation
|
||||
|
||||
In order to ease the use of ACL's we need to split the tailnet and users
|
||||
notion.
|
||||
|
||||
A solution could be to consider a headscale server (in it's entirety) as a
|
||||
tailnet.
|
||||
|
||||
For personal users the default behavior could either allow all communications
|
||||
between all namespaces (like tailscale) or dissallow all communications between
|
||||
namespaces (current behavior).
|
||||
|
||||
For businesses and organisations, viewing a headscale instance a single tailnet
|
||||
would allow users (namespace) to talk to each other with the ACLs. As described
|
||||
in tailscale's documentation [[1]], a server should be tagged and personnal
|
||||
devices should be tied to a user. Translated in headscale's terms each user can
|
||||
have multiple devices and all those devices should be in the same namespace.
|
||||
The servers should be tagged and used as such.
|
||||
|
||||
This implementation would render useless the sharing feature that is currently
|
||||
implemented since an ACL could do the same. Simplifying to only one user
|
||||
interface to do one thing is easier and less confusing for the users.
|
||||
|
||||
To better suit the ACLs in this proposition, it's advised to consider that each
|
||||
namespaces belong to one person. This person can have multiple devices, they
|
||||
will all be considered as the same user in the ACLs. OIDC feature wouldn't need
|
||||
to map people to namespace, just create a namespace if the person isn't
|
||||
registered yet.
|
||||
|
||||
As a sidenote, users would like to write ACLs as YAML. We should offer users
|
||||
the ability to rules in either format (HuJSON or YAML).
|
||||
|
||||
[1]: https://tailscale.com/kb/1068/acl-tags/
|
||||
|
||||
## Example
|
||||
|
||||
Let's build an example use case for a small business (It may be the place where
|
||||
ACL's are the most useful).
|
||||
|
||||
We have a small company with a boss, an admin, two developper and an intern.
|
||||
|
||||
The boss should have access to all servers but not to the users hosts. Admin
|
||||
should also have access to all hosts except that their permissions should be
|
||||
limited to maintaining the hosts (for example purposes). The developers can do
|
||||
anything they want on dev hosts, but only watch on productions hosts. Intern
|
||||
can only interact with the development servers.
|
||||
|
||||
Each user have at least a device connected to the network and we have some
|
||||
servers.
|
||||
|
||||
- database.prod
|
||||
- database.dev
|
||||
- app-server1.prod
|
||||
- app-server1.dev
|
||||
- billing.internal
|
||||
|
||||
### Current headscale implementation
|
||||
|
||||
Let's create some namespaces
|
||||
|
||||
```bash
|
||||
headscale namespaces create prod
|
||||
headscale namespaces create dev
|
||||
headscale namespaces create internal
|
||||
headscale namespaces create users
|
||||
|
||||
headscale nodes register -n users boss-computer
|
||||
headscale nodes register -n users admin1-computer
|
||||
headscale nodes register -n users dev1-computer
|
||||
headscale nodes register -n users dev1-phone
|
||||
headscale nodes register -n users dev2-computer
|
||||
headscale nodes register -n users intern1-computer
|
||||
|
||||
headscale nodes register -n prod database
|
||||
headscale nodes register -n prod app-server1
|
||||
|
||||
headscale nodes register -n dev database
|
||||
headscale nodes register -n dev app-server1
|
||||
|
||||
headscale nodes register -n internal billing
|
||||
|
||||
headscale nodes list
|
||||
ID | Name | Namespace | IP address
|
||||
1 | boss-computer | users | 100.64.0.1
|
||||
2 | admin1-computer | users | 100.64.0.2
|
||||
3 | dev1-computer | users | 100.64.0.3
|
||||
4 | dev1-phone | users | 100.64.0.4
|
||||
5 | dev2-computer | users | 100.64.0.5
|
||||
6 | intern1-computer | users | 100.64.0.6
|
||||
7 | database | prod | 100.64.0.7
|
||||
8 | app-server1 | prod | 100.64.0.8
|
||||
9 | database | dev | 100.64.0.9
|
||||
10 | app-server1 | dev | 100.64.0.10
|
||||
11 | internal | internal | 100.64.0.11
|
||||
```
|
||||
|
||||
In order to only allow the communications related to our description above we
|
||||
need to add the following ACLs
|
||||
|
||||
```json
|
||||
{
|
||||
"hosts": {
|
||||
"boss-computer": "100.64.0.1",
|
||||
"admin1-computer": "100.64.0.2",
|
||||
"dev1-computer": "100.64.0.3",
|
||||
"dev1-phone": "100.64.0.4",
|
||||
"dev2-computer": "100.64.0.5",
|
||||
"intern1-computer": "100.64.0.6",
|
||||
"prod-app-server1": "100.64.0.8"
|
||||
},
|
||||
"groups": {
|
||||
"group:dev": ["dev1-computer", "dev1-phone", "dev2-computer"],
|
||||
"group:admin": ["admin1-computer"],
|
||||
"group:boss": ["boss-computer"],
|
||||
"group:intern": ["intern1-computer"]
|
||||
},
|
||||
"acls": [
|
||||
// boss have access to all servers but no users hosts
|
||||
{
|
||||
"action": "accept",
|
||||
"users": ["group:boss"],
|
||||
"ports": ["prod:*", "dev:*", "internal:*"]
|
||||
},
|
||||
|
||||
// admin have access to adminstration port (lets only consider port 22 here)
|
||||
{
|
||||
"action": "accept",
|
||||
"users": ["group:admin"],
|
||||
"ports": ["prod:22", "dev:22", "internal:22"]
|
||||
},
|
||||
|
||||
// dev can do anything on dev servers and check access on prod servers
|
||||
{
|
||||
"action": "accept",
|
||||
"users": ["group:dev"],
|
||||
"ports": ["dev:*", "prod-app-server1:80,443"]
|
||||
},
|
||||
|
||||
// interns only have access to port 80 and 443 on dev servers (lame internship)
|
||||
{ "action": "accept", "users": ["group:intern"], "ports": ["dev:80,443"] },
|
||||
|
||||
// users can access their own devices
|
||||
{
|
||||
"action": "accept",
|
||||
"users": ["dev1-computer"],
|
||||
"ports": ["dev1-phone:*"]
|
||||
},
|
||||
{
|
||||
"action": "accept",
|
||||
"users": ["dev1-phone"],
|
||||
"ports": ["dev1-computer:*"]
|
||||
},
|
||||
|
||||
// internal namespace communications should still be allowed within the namespace
|
||||
{ "action": "accept", "users": ["dev"], "ports": ["dev:*"] },
|
||||
{ "action": "accept", "users": ["prod"], "ports": ["prod:*"] },
|
||||
{ "action": "accept", "users": ["internal"], "ports": ["internal:*"] }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Since communications between namespace isn't possible we also have to share the
|
||||
devices between the namespaces.
|
||||
|
||||
```bash
|
||||
|
||||
// add boss host to prod, dev and internal network
|
||||
headscale nodes share -i 1 -n prod
|
||||
headscale nodes share -i 1 -n dev
|
||||
headscale nodes share -i 1 -n internal
|
||||
|
||||
// add admin computer to prod, dev and internal network
|
||||
headscale nodes share -i 2 -n prod
|
||||
headscale nodes share -i 2 -n dev
|
||||
headscale nodes share -i 2 -n internal
|
||||
|
||||
// add all dev to prod and dev network
|
||||
headscale nodes share -i 3 -n dev
|
||||
headscale nodes share -i 4 -n dev
|
||||
headscale nodes share -i 3 -n prod
|
||||
headscale nodes share -i 4 -n prod
|
||||
headscale nodes share -i 5 -n dev
|
||||
headscale nodes share -i 5 -n prod
|
||||
|
||||
headscale nodes share -i 6 -n dev
|
||||
```
|
||||
|
||||
This fake network have not been tested but it should work. Operating it could
|
||||
be quite tedious if the company grows. Each time a new user join we have to add
|
||||
it to a group, and share it to the correct namespaces. If the user want
|
||||
multiple devices we have to allow communication to each of them one by one. If
|
||||
business conduct a change in the organisations we may have to rewrite all acls
|
||||
and reorganise all namespaces.
|
||||
|
||||
If we add servers in production we should also update the ACLs to allow dev
|
||||
access to certain category of them (only app servers for example).
|
||||
|
||||
### example based on the proposition in this document
|
||||
|
||||
Let's create the namespaces
|
||||
|
||||
```bash
|
||||
headscale namespaces create boss
|
||||
headscale namespaces create admin1
|
||||
headscale namespaces create dev1
|
||||
headscale namespaces create dev2
|
||||
headscale namespaces create intern1
|
||||
```
|
||||
|
||||
We don't need to create namespaces for the servers because the servers will be
|
||||
tagged. When registering the servers we will need to add the flag
|
||||
`--advertised-tags=tag:<tag1>,tag:<tag2>`, and the user (namespace) that is
|
||||
registering the server should be allowed to do it. Since anyone can add tags to
|
||||
a server they can register, the check of the tags is done on headscale server
|
||||
and only valid tags are applied. A tag is valid if the namespace that is
|
||||
registering it is allowed to do it.
|
||||
|
||||
Here are the ACL's to implement the same permissions as above:
|
||||
|
||||
```json
|
||||
{
|
||||
// groups are simpler and only list the namespaces name
|
||||
"groups": {
|
||||
"group:boss": ["boss"],
|
||||
"group:dev": ["dev1", "dev2"],
|
||||
"group:admin": ["admin1"],
|
||||
"group:intern": ["intern1"]
|
||||
},
|
||||
"tagOwners": {
|
||||
// the administrators can add servers in production
|
||||
"tag:prod-databases": ["group:admin"],
|
||||
"tag:prod-app-servers": ["group:admin"],
|
||||
|
||||
// the boss can tag any server as internal
|
||||
"tag:internal": ["group:boss"],
|
||||
|
||||
// dev can add servers for dev purposes as well as admins
|
||||
"tag:dev-databases": ["group:admin", "group:dev"],
|
||||
"tag:dev-app-servers": ["group:admin", "group:dev"]
|
||||
|
||||
// interns cannot add servers
|
||||
},
|
||||
"acls": [
|
||||
// boss have access to all servers
|
||||
{
|
||||
"action": "accept",
|
||||
"users": ["group:boss"],
|
||||
"ports": [
|
||||
"tag:prod-databases:*",
|
||||
"tag:prod-app-servers:*",
|
||||
"tag:internal:*",
|
||||
"tag:dev-databases:*",
|
||||
"tag:dev-app-servers:*"
|
||||
]
|
||||
},
|
||||
|
||||
// admin have only access to administrative ports of the servers
|
||||
{
|
||||
"action": "accept",
|
||||
"users": ["group:admin"],
|
||||
"ports": [
|
||||
"tag:prod-databases:22",
|
||||
"tag:prod-app-servers:22",
|
||||
"tag:internal:22",
|
||||
"tag:dev-databases:22",
|
||||
"tag:dev-app-servers:22"
|
||||
]
|
||||
},
|
||||
|
||||
{
|
||||
"action": "accept",
|
||||
"users": ["group:dev"],
|
||||
"ports": [
|
||||
"tag:dev-databases:*",
|
||||
"tag:dev-app-servers:*",
|
||||
"tag:prod-app-servers:80,443"
|
||||
]
|
||||
},
|
||||
|
||||
// servers should be able to talk to database. Database should not be able to initiate connections to server
|
||||
{
|
||||
"action": "accept",
|
||||
"users": ["tag:dev-app-servers"],
|
||||
"ports": ["tag:dev-databases:5432"]
|
||||
},
|
||||
{
|
||||
"action": "accept",
|
||||
"users": ["tag:prod-app-servers"],
|
||||
"ports": ["tag:prod-databases:5432"]
|
||||
},
|
||||
|
||||
// interns have access to dev-app-servers only in reading mode
|
||||
{
|
||||
"action": "accept",
|
||||
"users": ["group:intern"],
|
||||
"ports": ["tag:dev-app-servers:80,443"]
|
||||
},
|
||||
|
||||
// we still have to allow internal namespaces communications since nothing guarantees that each user have their own namespaces. This could be talked over.
|
||||
{ "action": "accept", "users": ["boss"], "ports": ["boss:*"] },
|
||||
{ "action": "accept", "users": ["dev1"], "ports": ["dev1:*"] },
|
||||
{ "action": "accept", "users": ["dev2"], "ports": ["dev2:*"] },
|
||||
{ "action": "accept", "users": ["admin1"], "ports": ["admin1:*"] },
|
||||
{ "action": "accept", "users": ["intern1"], "ports": ["intern1:*"] }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
With this implementation, the sharing step is not necessary. Maintenance cost
|
||||
of the ACL file is lower and less tedious (no need to map hostname and IP's
|
||||
into it).
|
||||
100
docs/remote-cli.md
Normal file
100
docs/remote-cli.md
Normal file
@@ -0,0 +1,100 @@
|
||||
# Controlling `headscale` with remote CLI
|
||||
|
||||
## Prerequisit
|
||||
|
||||
- A workstation to run `headscale` (could be Linux, macOS, other supported platforms)
|
||||
- A `headscale` server (version `0.13.0` or newer)
|
||||
- Access to create API keys (local access to the `headscale` server)
|
||||
- `headscale` _must_ be served over TLS/HTTPS
|
||||
- Remote access does _not_ support unencrypted traffic.
|
||||
- Port `50443` must be open in the firewall (or port overriden by `grpc_listen_addr` option)
|
||||
|
||||
## Goal
|
||||
|
||||
This documentation has the goal of showing a user how-to set control a `headscale` instance
|
||||
from a remote machine with the `headscale` command line binary.
|
||||
|
||||
## Create an API key
|
||||
|
||||
We need to create an API key to authenticate our remote `headscale` when using it from our workstation.
|
||||
|
||||
To create a API key, log into your `headscale` server and generate a key:
|
||||
|
||||
```shell
|
||||
headscale apikeys create --expiration 90d
|
||||
```
|
||||
|
||||
Copy the output of the command and save it for later. Please not that you can not retrieve a key again,
|
||||
if the key is lost, expire the old one, and create a new key.
|
||||
|
||||
To list the keys currently assosicated with the server:
|
||||
|
||||
```shell
|
||||
headscale apikeys list
|
||||
```
|
||||
|
||||
and to expire a key:
|
||||
|
||||
```shell
|
||||
headscale apikeys expire --prefix "<PREFIX>"
|
||||
```
|
||||
|
||||
## Download and configure `headscale`
|
||||
|
||||
1. Download the latest [`headscale` binary from GitHub's release page](https://github.com/juanfont/headscale/releases):
|
||||
|
||||
2. Put the binary somewhere in your `PATH`, e.g. `/usr/local/bin/headcale`
|
||||
|
||||
3. Make `headscale` executable:
|
||||
|
||||
```shell
|
||||
chmod +x /usr/local/bin/headscale
|
||||
```
|
||||
|
||||
4. Configure the CLI through Environment Variables
|
||||
|
||||
```shell
|
||||
export HEADSCALE_CLI_ADDRESS="<HEADSCALE ADDRESS>:<PORT>"
|
||||
export HEADSCALE_CLI_API_KEY="<API KEY FROM PREVIOUS STAGE>"
|
||||
```
|
||||
|
||||
for example:
|
||||
|
||||
```shell
|
||||
export HEADSCALE_CLI_ADDRESS="headscale.example.com:50443"
|
||||
export HEADSCALE_CLI_API_KEY="abcde12345"
|
||||
```
|
||||
|
||||
This will tell the `headscale` binary to connect to a remote instance, instead of looking
|
||||
for a local instance (which is what it does on the server).
|
||||
|
||||
The API key is needed to make sure that your are allowed to access the server. The key is _not_
|
||||
needed when running directly on the server, as the connection is local.
|
||||
|
||||
5. Test the connection
|
||||
|
||||
Let us run the headscale command to verify that we can connect by listing our nodes:
|
||||
|
||||
```shell
|
||||
headscale nodes list
|
||||
```
|
||||
|
||||
You should now be able to see a list of your nodes from your workstation, and you can
|
||||
now control the `headscale` server from your workstation.
|
||||
|
||||
## Behind a proxy
|
||||
|
||||
It is possible to run the gRPC remote endpoint behind a reverse proxy, like Nginx, and have it run on the _same_ port as `headscale`.
|
||||
|
||||
While this is _not a supported_ feature, an example on how this can be set up on
|
||||
[NixOS is shown here](https://github.com/kradalby/dotfiles/blob/4489cdbb19cddfbfae82cd70448a38fde5a76711/machines/headscale.oracldn/headscale.nix#L61-L91).
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
Checklist:
|
||||
|
||||
- Make sure you have the _same_ `headscale` version on your server and workstation
|
||||
- Make sure you use version `0.13.0` or newer.
|
||||
- Verify that your TLS certificate is valid and trusted
|
||||
- If you do not have access to a trusted certificate (e.g. from Let's Encrypt), add your self signed certificate to the trust store of your OS or
|
||||
- Set `HEADSCALE_CLI_INSECURE` to 0 in your environement
|
||||
149
docs/running-headscale-container.md
Normal file
149
docs/running-headscale-container.md
Normal file
@@ -0,0 +1,149 @@
|
||||
# Running headscale in a container
|
||||
|
||||
**Note:** the container documentation is maintained by the _community_ and there is no guarentee
|
||||
it is up to date, or working.
|
||||
|
||||
## Goal
|
||||
|
||||
This documentation has the goal of showing a user how-to set up and run `headscale` in a container.
|
||||
[Docker](https://www.docker.com) is used as the reference container implementation, but there is no reason that it should
|
||||
not work with alternatives like [Podman](https://podman.io). The Docker image can be found on Docker Hub [here](https://hub.docker.com/r/headscale/headscale).
|
||||
|
||||
## Configure and run `headscale`
|
||||
|
||||
1. Prepare a directory on the host Docker node in your directory of choice, used to hold `headscale` configuration and the [SQLite](https://www.sqlite.org/) database:
|
||||
|
||||
```shell
|
||||
mkdir ./headscale && cd ./headscale
|
||||
mkdir ./config
|
||||
```
|
||||
|
||||
2. Create an empty SQlite datebase in the headscale directory:
|
||||
|
||||
```shell
|
||||
touch ./config/db.sqlite
|
||||
```
|
||||
|
||||
3. **(Strongly Recommended)** Download a copy of the [example configuration](../config-example.yaml) from the [headscale repository](https://github.com/juanfont/headscale/).
|
||||
|
||||
Using wget:
|
||||
|
||||
```shell
|
||||
wget -O ./config/config.yaml https://raw.githubusercontent.com/juanfont/headscale/main/config-example.yaml
|
||||
```
|
||||
|
||||
Using curl:
|
||||
|
||||
```shell
|
||||
curl https://raw.githubusercontent.com/juanfont/headscale/main/config-example.yaml -o ./config/config.yaml
|
||||
```
|
||||
|
||||
**(Advanced)** If you would like to hand craft a config file **instead** of downloading the example config file, create a blank `headscale` configuration in the headscale directory to edit:
|
||||
|
||||
```shell
|
||||
touch ./config/config.yaml
|
||||
```
|
||||
|
||||
Modify the config file to your preferences before launching Docker container.
|
||||
|
||||
4. Start the headscale server while working in the host headscale directory:
|
||||
|
||||
```shell
|
||||
docker run \
|
||||
--name headscale \
|
||||
--detach \
|
||||
--rm \
|
||||
--volume $(pwd)/config:/etc/headscale/ \
|
||||
--publish 127.0.0.1:8080:8080 \
|
||||
--publish 127.0.0.1:9090:9090 \
|
||||
headscale/headscale:<VERSION> \
|
||||
headscale serve
|
||||
|
||||
```
|
||||
|
||||
This command will mount `config/` under `/etc/headscale`, forward port 8080 out of the container so the
|
||||
`headscale` instance becomes available and then detach so headscale runs in the background.
|
||||
|
||||
5. Verify `headscale` is running:
|
||||
|
||||
Follow the container logs:
|
||||
|
||||
```shell
|
||||
docker logs --follow headscale
|
||||
```
|
||||
|
||||
Verify running containers:
|
||||
|
||||
```shell
|
||||
docker ps
|
||||
```
|
||||
|
||||
Verify `headscale` is available:
|
||||
|
||||
```shell
|
||||
curl http://127.0.0.1:9090/metrics
|
||||
```
|
||||
|
||||
6. Create a namespace ([tailnet](https://tailscale.com/kb/1136/tailnet/)):
|
||||
|
||||
```shell
|
||||
docker exec headscale -- headscale namespaces create myfirstnamespace
|
||||
```
|
||||
|
||||
### Register a machine (normal login)
|
||||
|
||||
On a client machine, execute the `tailscale` login command:
|
||||
|
||||
```shell
|
||||
tailscale up --login-server YOUR_HEADSCALE_URL
|
||||
```
|
||||
|
||||
To register a machine when running `headscale` in a container, take the headscale command and pass it to the container:
|
||||
|
||||
```shell
|
||||
docker exec headscale -- \
|
||||
headscale --namespace myfirstnamespace nodes register --key <YOU_+MACHINE_KEY>
|
||||
```
|
||||
|
||||
### Register machine using a pre authenticated key
|
||||
|
||||
Generate a key using the command line:
|
||||
|
||||
```shell
|
||||
docker exec headscale -- \
|
||||
headscale --namespace myfirstnamespace preauthkeys create --reusable --expiration 24h
|
||||
```
|
||||
|
||||
This will return a pre-authenticated key that can be used to connect a node to `headscale` during the `tailscale` command:
|
||||
|
||||
```shell
|
||||
tailscale up --login-server <YOUR_HEADSCALE_URL> --authkey <YOUR_AUTH_KEY>
|
||||
```
|
||||
|
||||
## Debugging headscale running in Docker
|
||||
|
||||
The `headscale/headscale` Docker container is based on a "distroless" image that does not contain a shell or any other debug tools. If you need to debug your application running in the Docker container, you can use the `-debug` variant, for example `headscale/headscale:x.x.x-debug`.
|
||||
|
||||
### Running the debug Docker container
|
||||
|
||||
To run the debug Docker container, use the exact same commands as above, but replace `headscale/headscale:x.x.x` with `headscale/headscale:x.x.x-debug` (`x.x.x` is the version of headscale). The two containers are compatible with each other, so you can alternate between them.
|
||||
|
||||
### Executing commands in the debug container
|
||||
|
||||
The default command in the debug container is to run `headscale`, which is located at `/bin/headscale` inside the container.
|
||||
|
||||
Additionally, the debug container includes a minimalist Busybox shell.
|
||||
|
||||
To launch a shell in the container, use:
|
||||
|
||||
```
|
||||
docker run -it headscale/headscale:x.x.x-debug sh
|
||||
```
|
||||
|
||||
You can also execute commands directly, such as `ls /bin` in this example:
|
||||
|
||||
```
|
||||
docker run headscale/headscale:x.x.x-debug ls /bin
|
||||
```
|
||||
|
||||
Using `docker exec` allows you to run commands in an existing container.
|
||||
184
docs/running-headscale-linux.md
Normal file
184
docs/running-headscale-linux.md
Normal file
@@ -0,0 +1,184 @@
|
||||
# Running headscale on Linux
|
||||
|
||||
## Goal
|
||||
|
||||
This documentation has the goal of showing a user how-to set up and run `headscale` on Linux.
|
||||
In additional to the "get up and running section", there is an optional [SystemD section](#running-headscale-in-the-background-with-systemd)
|
||||
describing how to make `headscale` run properly in a server environment.
|
||||
|
||||
## Configure and run `headscale`
|
||||
|
||||
1. Download the latest [`headscale` binary from GitHub's release page](https://github.com/juanfont/headscale/releases):
|
||||
|
||||
```shell
|
||||
wget --output-document=/usr/local/bin/headscale \
|
||||
https://github.com/juanfont/headscale/releases/download/v<HEADSCALE VERSION>/headscale_<HEADSCALE VERSION>_linux_<ARCH>
|
||||
```
|
||||
|
||||
2. Make `headscale` executable:
|
||||
|
||||
```shell
|
||||
chmod +x /usr/local/bin/headscale
|
||||
```
|
||||
|
||||
3. Prepare a directory to hold `headscale` configuration and the [SQLite](https://www.sqlite.org/) database:
|
||||
|
||||
```shell
|
||||
# Directory for configuration
|
||||
|
||||
mkdir -p /etc/headscale
|
||||
|
||||
# Directory for Database, and other variable data (like certificates)
|
||||
mkdir -p /var/lib/headscale
|
||||
```
|
||||
|
||||
4. Create an empty SQLite database:
|
||||
|
||||
```shell
|
||||
touch /var/lib/headscale/db.sqlite
|
||||
```
|
||||
|
||||
5. Create a `headscale` configuration:
|
||||
|
||||
```shell
|
||||
touch /etc/headscale/config.yaml
|
||||
```
|
||||
|
||||
It is **strongly recommended** to copy and modify the [example configuration](../config-example.yaml)
|
||||
from the [headscale repository](../)
|
||||
|
||||
6. Start the headscale server:
|
||||
|
||||
```shell
|
||||
headscale serve
|
||||
```
|
||||
|
||||
This command will start `headscale` in the current terminal session.
|
||||
|
||||
---
|
||||
|
||||
To continue the tutorial, open a new terminal and let it run in the background.
|
||||
Alternatively use terminal emulators like [tmux](https://github.com/tmux/tmux) or [screen](https://www.gnu.org/software/screen/).
|
||||
|
||||
To run `headscale` in the background, please follow the steps in the [SystemD section](#running-headscale-in-the-background-with-systemd) before continuing.
|
||||
|
||||
7. Verify `headscale` is running:
|
||||
|
||||
Verify `headscale` is available:
|
||||
|
||||
```shell
|
||||
curl http://127.0.0.1:9090/metrics
|
||||
```
|
||||
|
||||
8. Create a namespace ([tailnet](https://tailscale.com/kb/1136/tailnet/)):
|
||||
|
||||
```shell
|
||||
headscale namespaces create myfirstnamespace
|
||||
```
|
||||
|
||||
### Register a machine (normal login)
|
||||
|
||||
On a client machine, execute the `tailscale` login command:
|
||||
|
||||
```shell
|
||||
tailscale up --login-server YOUR_HEADSCALE_URL
|
||||
```
|
||||
|
||||
Register the machine:
|
||||
|
||||
```shell
|
||||
headscale --namespace myfirstnamespace nodes register --key <YOU_+MACHINE_KEY>
|
||||
```
|
||||
|
||||
### Register machine using a pre authenticated key
|
||||
|
||||
Generate a key using the command line:
|
||||
|
||||
```shell
|
||||
headscale --namespace myfirstnamespace preauthkeys create --reusable --expiration 24h
|
||||
```
|
||||
|
||||
This will return a pre-authenticated key that can be used to connect a node to `headscale` during the `tailscale` command:
|
||||
|
||||
```shell
|
||||
tailscale up --login-server <YOUR_HEADSCALE_URL> --authkey <YOUR_AUTH_KEY>
|
||||
```
|
||||
|
||||
## Running `headscale` in the background with SystemD
|
||||
|
||||
This section demonstrates how to run `headscale` as a service in the background with [SystemD](https://www.freedesktop.org/wiki/Software/systemd/).
|
||||
This should work on most modern Linux distributions.
|
||||
|
||||
1. Create a SystemD service configuration at `/etc/systemd/system/headscale.service` containing:
|
||||
|
||||
```systemd
|
||||
[Unit]
|
||||
Description=headscale controller
|
||||
After=syslog.target
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=headscale
|
||||
Group=headscale
|
||||
ExecStart=/usr/local/bin/headscale serve
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
|
||||
# Optional security enhancements
|
||||
NoNewPrivileges=yes
|
||||
PrivateTmp=yes
|
||||
ProtectSystem=strict
|
||||
ProtectHome=yes
|
||||
ReadWritePaths=/var/lib/headscale /var/run/headscale
|
||||
AmbientCapabilities=CAP_NET_BIND_SERVICE
|
||||
RuntimeDirectory=headscale
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
Note that when running as the headscale user ensure that, either you add your current user to the headscale group:
|
||||
|
||||
```shell
|
||||
usermod -a -G headscale current_user
|
||||
```
|
||||
|
||||
or run all headscale commands as the headscale user:
|
||||
|
||||
```shell
|
||||
su - headscale
|
||||
```
|
||||
|
||||
2. In `/etc/headscale/config.yaml`, override the default `headscale` unix socket with a SystemD friendly path:
|
||||
|
||||
```yaml
|
||||
unix_socket: /var/run/headscale/headscale.sock
|
||||
```
|
||||
|
||||
3. Reload SystemD to load the new configuration file:
|
||||
|
||||
```shell
|
||||
systemctl daemon-reload
|
||||
```
|
||||
|
||||
4. Enable and start the new `headscale` service:
|
||||
|
||||
```shell
|
||||
systemctl enable headscale
|
||||
systemctl start headscale
|
||||
```
|
||||
|
||||
5. Verify the headscale service:
|
||||
|
||||
```shell
|
||||
systemctl status headscale
|
||||
```
|
||||
|
||||
Verify `headscale` is available:
|
||||
|
||||
```shell
|
||||
curl http://127.0.0.1:8080/metrics
|
||||
```
|
||||
|
||||
`headscale` will now run in the background and start at boot.
|
||||
45
docs/tls.md
Normal file
45
docs/tls.md
Normal file
@@ -0,0 +1,45 @@
|
||||
# Running the service via TLS (optional)
|
||||
|
||||
## Let's Encrypt / ACME
|
||||
|
||||
To get a certificate automatically via [Let's Encrypt](https://letsencrypt.org/), set `tls_letsencrypt_hostname` to the desired certificate hostname. This name must resolve to the IP address(es) headscale is reachable on (i.e., it must correspond to the `server_url` configuration parameter). The certificate and Let's Encrypt account credentials will be stored in the directory configured in `tls_letsencrypt_cache_dir`. If the path is relative, it will be interpreted as relative to the directory the configuration file was read from. The certificate will automatically be renewed as needed.
|
||||
|
||||
```yaml
|
||||
tls_letsencrypt_hostname: ""
|
||||
tls_letsencrypt_listen: ":http"
|
||||
tls_letsencrypt_cache_dir: ".cache"
|
||||
tls_letsencrypt_challenge_type: HTTP-01
|
||||
```
|
||||
|
||||
### Challenge type HTTP-01
|
||||
|
||||
The default challenge type `HTTP-01` requires that headscale is reachable on port 80 for the Let's Encrypt automated validation, in addition to whatever port is configured in `listen_addr`. By default, headscale listens on port 80 on all local IPs for Let's Encrypt automated validation.
|
||||
|
||||
If you need to change the ip and/or port used by headscale for the Let's Encrypt validation process, set `tls_letsencrypt_listen` to the appropriate value. This can be handy if you are running headscale as a non-root user (or can't run `setcap`). Keep in mind, however, that Let's Encrypt will _only_ connect to port 80 for the validation callback, so if you change `tls_letsencrypt_listen` you will also need to configure something else (e.g. a firewall rule) to forward the traffic from port 80 to the ip:port combination specified in `tls_letsencrypt_listen`.
|
||||
|
||||
### Challenge type TLS-ALPN-01
|
||||
|
||||
Alternatively, `tls_letsencrypt_challenge_type` can be set to `TLS-ALPN-01`. In this configuration, headscale listens on the ip:port combination defined in `listen_addr`. Let's Encrypt will _only_ connect to port 443 for the validation callback, so if `listen_addr` is not set to port 443, something else (e.g. a firewall rule) will be required to forward the traffic from port 443 to the ip:port combination specified in `listen_addr`.
|
||||
|
||||
## Bring your own certificate
|
||||
|
||||
headscale can also be configured to expose its web service via TLS. To configure the certificate and key file manually, set the `tls_cert_path` and `tls_cert_path` configuration parameters. If the path is relative, it will be interpreted as relative to the directory the configuration file was read from.
|
||||
|
||||
```yaml
|
||||
tls_cert_path: ""
|
||||
tls_key_path: ""
|
||||
```
|
||||
|
||||
### Configuring Mutual TLS Authentication (mTLS)
|
||||
|
||||
mTLS is a method by which an HTTPS server authenticates clients, e.g. Tailscale, using TLS certificates. This can be configured by applying one of the following values to the `tls_client_auth_mode` setting in the configuration file.
|
||||
|
||||
| Value | Behavior |
|
||||
| ------------------- | ---------------------------------------------------------- |
|
||||
| `disabled` | Disable mTLS. |
|
||||
| `relaxed` (default) | A client certificate is required, but it is not verified. |
|
||||
| `enforced` | Requires clients to supply a certificate that is verified. |
|
||||
|
||||
```yaml
|
||||
tls_client_auth_mode: ""
|
||||
```
|
||||
50
docs/windows-client.md
Normal file
50
docs/windows-client.md
Normal file
@@ -0,0 +1,50 @@
|
||||
# Connecting a Windows client
|
||||
|
||||
## Goal
|
||||
|
||||
This documentation has the goal of showing how a user can use the official Windows [Tailscale](https://tailscale.com) client with `headscale`.
|
||||
|
||||
## Add registry keys
|
||||
|
||||
To make the Windows client behave as expected and to run well with `headscale`, two registry keys **must** be set:
|
||||
|
||||
- `HKLM:\SOFTWARE\Tailscale IPN\UnattendedMode` must be set to `always` as a `string` type, to allow Tailscale to run properly in the background
|
||||
- `HKLM:\SOFTWARE\Tailscale IPN\LoginURL` must be set to `<YOUR HEADSCALE URL>` as a `string` type, to ensure Tailscale contacts the correct control server.
|
||||
|
||||

|
||||
|
||||
The Tailscale Windows client has been observed to reset its configuration on logout/reboot and these two keys [resolves that issue](https://github.com/tailscale/tailscale/issues/2798).
|
||||
|
||||
For a guide on how to edit registry keys, [check out Computer Hope](https://www.computerhope.com/issues/ch001348.htm).
|
||||
|
||||
## Installation
|
||||
|
||||
Download the [Official Windows Client](https://tailscale.com/download/windows) and install it.
|
||||
|
||||
When the installation has finished, start Tailscale and log in (you might have to click the icon in the system tray).
|
||||
|
||||
The log in should open a browser Window and direct you to your `headscale` instance.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
If you are seeing repeated messages like:
|
||||
|
||||
```
|
||||
[GIN] 2022/02/10 - 16:39:34 | 200 | 1.105306ms | 127.0.0.1 | POST "/machine/redacted"
|
||||
```
|
||||
|
||||
in your `headscale` output, turn on `DEBUG` logging and look for:
|
||||
|
||||
```
|
||||
2022-02-11T00:59:29Z DBG Machine registration has expired. Sending a authurl to register machine=redacted
|
||||
```
|
||||
|
||||
This typically means that the registry keys above was not set appropriately.
|
||||
|
||||
To reset and try again, it is important to do the following:
|
||||
|
||||
1. Ensure the registry keys from the previous guide is correctly set.
|
||||
2. Shut down the Tailscale service (or the client running in the tray)
|
||||
3. Delete Tailscale Application data folder, located at `C:\Users\<USERNAME>\AppData\Local\Tailscale` and try to connect again.
|
||||
4. Ensure the Windows node is deleted from headscale (to ensure fresh setup)
|
||||
5. Start Tailscale on the windows machine and retry the login.
|
||||
558
gen/go/headscale/v1/apikey.pb.go
Normal file
558
gen/go/headscale/v1/apikey.pb.go
Normal file
@@ -0,0 +1,558 @@
|
||||
// Code generated by protoc-gen-go. DO NOT EDIT.
|
||||
// versions:
|
||||
// protoc-gen-go v1.27.1
|
||||
// protoc (unknown)
|
||||
// source: headscale/v1/apikey.proto
|
||||
|
||||
package v1
|
||||
|
||||
import (
|
||||
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
|
||||
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
|
||||
timestamppb "google.golang.org/protobuf/types/known/timestamppb"
|
||||
reflect "reflect"
|
||||
sync "sync"
|
||||
)
|
||||
|
||||
const (
|
||||
// Verify that this generated code is sufficiently up-to-date.
|
||||
_ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
|
||||
// Verify that runtime/protoimpl is sufficiently up-to-date.
|
||||
_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
|
||||
)
|
||||
|
||||
type ApiKey struct {
|
||||
state protoimpl.MessageState
|
||||
sizeCache protoimpl.SizeCache
|
||||
unknownFields protoimpl.UnknownFields
|
||||
|
||||
Id uint64 `protobuf:"varint,1,opt,name=id,proto3" json:"id,omitempty"`
|
||||
Prefix string `protobuf:"bytes,2,opt,name=prefix,proto3" json:"prefix,omitempty"`
|
||||
Expiration *timestamppb.Timestamp `protobuf:"bytes,3,opt,name=expiration,proto3" json:"expiration,omitempty"`
|
||||
CreatedAt *timestamppb.Timestamp `protobuf:"bytes,4,opt,name=created_at,json=createdAt,proto3" json:"created_at,omitempty"`
|
||||
LastSeen *timestamppb.Timestamp `protobuf:"bytes,5,opt,name=last_seen,json=lastSeen,proto3" json:"last_seen,omitempty"`
|
||||
}
|
||||
|
||||
func (x *ApiKey) Reset() {
|
||||
*x = ApiKey{}
|
||||
if protoimpl.UnsafeEnabled {
|
||||
mi := &file_headscale_v1_apikey_proto_msgTypes[0]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
}
|
||||
|
||||
func (x *ApiKey) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*ApiKey) ProtoMessage() {}
|
||||
|
||||
func (x *ApiKey) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_headscale_v1_apikey_proto_msgTypes[0]
|
||||
if protoimpl.UnsafeEnabled && x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use ApiKey.ProtoReflect.Descriptor instead.
|
||||
func (*ApiKey) Descriptor() ([]byte, []int) {
|
||||
return file_headscale_v1_apikey_proto_rawDescGZIP(), []int{0}
|
||||
}
|
||||
|
||||
func (x *ApiKey) GetId() uint64 {
|
||||
if x != nil {
|
||||
return x.Id
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (x *ApiKey) GetPrefix() string {
|
||||
if x != nil {
|
||||
return x.Prefix
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *ApiKey) GetExpiration() *timestamppb.Timestamp {
|
||||
if x != nil {
|
||||
return x.Expiration
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (x *ApiKey) GetCreatedAt() *timestamppb.Timestamp {
|
||||
if x != nil {
|
||||
return x.CreatedAt
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (x *ApiKey) GetLastSeen() *timestamppb.Timestamp {
|
||||
if x != nil {
|
||||
return x.LastSeen
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type CreateApiKeyRequest struct {
|
||||
state protoimpl.MessageState
|
||||
sizeCache protoimpl.SizeCache
|
||||
unknownFields protoimpl.UnknownFields
|
||||
|
||||
Expiration *timestamppb.Timestamp `protobuf:"bytes,1,opt,name=expiration,proto3" json:"expiration,omitempty"`
|
||||
}
|
||||
|
||||
func (x *CreateApiKeyRequest) Reset() {
|
||||
*x = CreateApiKeyRequest{}
|
||||
if protoimpl.UnsafeEnabled {
|
||||
mi := &file_headscale_v1_apikey_proto_msgTypes[1]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
}
|
||||
|
||||
func (x *CreateApiKeyRequest) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*CreateApiKeyRequest) ProtoMessage() {}
|
||||
|
||||
func (x *CreateApiKeyRequest) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_headscale_v1_apikey_proto_msgTypes[1]
|
||||
if protoimpl.UnsafeEnabled && x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use CreateApiKeyRequest.ProtoReflect.Descriptor instead.
|
||||
func (*CreateApiKeyRequest) Descriptor() ([]byte, []int) {
|
||||
return file_headscale_v1_apikey_proto_rawDescGZIP(), []int{1}
|
||||
}
|
||||
|
||||
func (x *CreateApiKeyRequest) GetExpiration() *timestamppb.Timestamp {
|
||||
if x != nil {
|
||||
return x.Expiration
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type CreateApiKeyResponse struct {
|
||||
state protoimpl.MessageState
|
||||
sizeCache protoimpl.SizeCache
|
||||
unknownFields protoimpl.UnknownFields
|
||||
|
||||
ApiKey string `protobuf:"bytes,1,opt,name=api_key,json=apiKey,proto3" json:"api_key,omitempty"`
|
||||
}
|
||||
|
||||
func (x *CreateApiKeyResponse) Reset() {
|
||||
*x = CreateApiKeyResponse{}
|
||||
if protoimpl.UnsafeEnabled {
|
||||
mi := &file_headscale_v1_apikey_proto_msgTypes[2]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
}
|
||||
|
||||
func (x *CreateApiKeyResponse) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*CreateApiKeyResponse) ProtoMessage() {}
|
||||
|
||||
func (x *CreateApiKeyResponse) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_headscale_v1_apikey_proto_msgTypes[2]
|
||||
if protoimpl.UnsafeEnabled && x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use CreateApiKeyResponse.ProtoReflect.Descriptor instead.
|
||||
func (*CreateApiKeyResponse) Descriptor() ([]byte, []int) {
|
||||
return file_headscale_v1_apikey_proto_rawDescGZIP(), []int{2}
|
||||
}
|
||||
|
||||
func (x *CreateApiKeyResponse) GetApiKey() string {
|
||||
if x != nil {
|
||||
return x.ApiKey
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type ExpireApiKeyRequest struct {
|
||||
state protoimpl.MessageState
|
||||
sizeCache protoimpl.SizeCache
|
||||
unknownFields protoimpl.UnknownFields
|
||||
|
||||
Prefix string `protobuf:"bytes,1,opt,name=prefix,proto3" json:"prefix,omitempty"`
|
||||
}
|
||||
|
||||
func (x *ExpireApiKeyRequest) Reset() {
|
||||
*x = ExpireApiKeyRequest{}
|
||||
if protoimpl.UnsafeEnabled {
|
||||
mi := &file_headscale_v1_apikey_proto_msgTypes[3]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
}
|
||||
|
||||
func (x *ExpireApiKeyRequest) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*ExpireApiKeyRequest) ProtoMessage() {}
|
||||
|
||||
func (x *ExpireApiKeyRequest) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_headscale_v1_apikey_proto_msgTypes[3]
|
||||
if protoimpl.UnsafeEnabled && x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use ExpireApiKeyRequest.ProtoReflect.Descriptor instead.
|
||||
func (*ExpireApiKeyRequest) Descriptor() ([]byte, []int) {
|
||||
return file_headscale_v1_apikey_proto_rawDescGZIP(), []int{3}
|
||||
}
|
||||
|
||||
func (x *ExpireApiKeyRequest) GetPrefix() string {
|
||||
if x != nil {
|
||||
return x.Prefix
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type ExpireApiKeyResponse struct {
|
||||
state protoimpl.MessageState
|
||||
sizeCache protoimpl.SizeCache
|
||||
unknownFields protoimpl.UnknownFields
|
||||
}
|
||||
|
||||
func (x *ExpireApiKeyResponse) Reset() {
|
||||
*x = ExpireApiKeyResponse{}
|
||||
if protoimpl.UnsafeEnabled {
|
||||
mi := &file_headscale_v1_apikey_proto_msgTypes[4]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
}
|
||||
|
||||
func (x *ExpireApiKeyResponse) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*ExpireApiKeyResponse) ProtoMessage() {}
|
||||
|
||||
func (x *ExpireApiKeyResponse) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_headscale_v1_apikey_proto_msgTypes[4]
|
||||
if protoimpl.UnsafeEnabled && x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use ExpireApiKeyResponse.ProtoReflect.Descriptor instead.
|
||||
func (*ExpireApiKeyResponse) Descriptor() ([]byte, []int) {
|
||||
return file_headscale_v1_apikey_proto_rawDescGZIP(), []int{4}
|
||||
}
|
||||
|
||||
type ListApiKeysRequest struct {
|
||||
state protoimpl.MessageState
|
||||
sizeCache protoimpl.SizeCache
|
||||
unknownFields protoimpl.UnknownFields
|
||||
}
|
||||
|
||||
func (x *ListApiKeysRequest) Reset() {
|
||||
*x = ListApiKeysRequest{}
|
||||
if protoimpl.UnsafeEnabled {
|
||||
mi := &file_headscale_v1_apikey_proto_msgTypes[5]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
}
|
||||
|
||||
func (x *ListApiKeysRequest) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*ListApiKeysRequest) ProtoMessage() {}
|
||||
|
||||
func (x *ListApiKeysRequest) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_headscale_v1_apikey_proto_msgTypes[5]
|
||||
if protoimpl.UnsafeEnabled && x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use ListApiKeysRequest.ProtoReflect.Descriptor instead.
|
||||
func (*ListApiKeysRequest) Descriptor() ([]byte, []int) {
|
||||
return file_headscale_v1_apikey_proto_rawDescGZIP(), []int{5}
|
||||
}
|
||||
|
||||
type ListApiKeysResponse struct {
|
||||
state protoimpl.MessageState
|
||||
sizeCache protoimpl.SizeCache
|
||||
unknownFields protoimpl.UnknownFields
|
||||
|
||||
ApiKeys []*ApiKey `protobuf:"bytes,1,rep,name=api_keys,json=apiKeys,proto3" json:"api_keys,omitempty"`
|
||||
}
|
||||
|
||||
func (x *ListApiKeysResponse) Reset() {
|
||||
*x = ListApiKeysResponse{}
|
||||
if protoimpl.UnsafeEnabled {
|
||||
mi := &file_headscale_v1_apikey_proto_msgTypes[6]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
}
|
||||
|
||||
func (x *ListApiKeysResponse) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*ListApiKeysResponse) ProtoMessage() {}
|
||||
|
||||
func (x *ListApiKeysResponse) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_headscale_v1_apikey_proto_msgTypes[6]
|
||||
if protoimpl.UnsafeEnabled && x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use ListApiKeysResponse.ProtoReflect.Descriptor instead.
|
||||
func (*ListApiKeysResponse) Descriptor() ([]byte, []int) {
|
||||
return file_headscale_v1_apikey_proto_rawDescGZIP(), []int{6}
|
||||
}
|
||||
|
||||
func (x *ListApiKeysResponse) GetApiKeys() []*ApiKey {
|
||||
if x != nil {
|
||||
return x.ApiKeys
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var File_headscale_v1_apikey_proto protoreflect.FileDescriptor
|
||||
|
||||
var file_headscale_v1_apikey_proto_rawDesc = []byte{
|
||||
0x0a, 0x19, 0x68, 0x65, 0x61, 0x64, 0x73, 0x63, 0x61, 0x6c, 0x65, 0x2f, 0x76, 0x31, 0x2f, 0x61,
|
||||
0x70, 0x69, 0x6b, 0x65, 0x79, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x0c, 0x68, 0x65, 0x61,
|
||||
0x64, 0x73, 0x63, 0x61, 0x6c, 0x65, 0x2e, 0x76, 0x31, 0x1a, 0x1f, 0x67, 0x6f, 0x6f, 0x67, 0x6c,
|
||||
0x65, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2f, 0x74, 0x69, 0x6d, 0x65, 0x73,
|
||||
0x74, 0x61, 0x6d, 0x70, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x22, 0xe0, 0x01, 0x0a, 0x06, 0x41,
|
||||
0x70, 0x69, 0x4b, 0x65, 0x79, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28,
|
||||
0x04, 0x52, 0x02, 0x69, 0x64, 0x12, 0x16, 0x0a, 0x06, 0x70, 0x72, 0x65, 0x66, 0x69, 0x78, 0x18,
|
||||
0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x70, 0x72, 0x65, 0x66, 0x69, 0x78, 0x12, 0x3a, 0x0a,
|
||||
0x0a, 0x65, 0x78, 0x70, 0x69, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28,
|
||||
0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f,
|
||||
0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x52, 0x0a, 0x65,
|
||||
0x78, 0x70, 0x69, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x39, 0x0a, 0x0a, 0x63, 0x72, 0x65,
|
||||
0x61, 0x74, 0x65, 0x64, 0x5f, 0x61, 0x74, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e,
|
||||
0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e,
|
||||
0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x52, 0x09, 0x63, 0x72, 0x65, 0x61, 0x74,
|
||||
0x65, 0x64, 0x41, 0x74, 0x12, 0x37, 0x0a, 0x09, 0x6c, 0x61, 0x73, 0x74, 0x5f, 0x73, 0x65, 0x65,
|
||||
0x6e, 0x18, 0x05, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65,
|
||||
0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74,
|
||||
0x61, 0x6d, 0x70, 0x52, 0x08, 0x6c, 0x61, 0x73, 0x74, 0x53, 0x65, 0x65, 0x6e, 0x22, 0x51, 0x0a,
|
||||
0x13, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x41, 0x70, 0x69, 0x4b, 0x65, 0x79, 0x52, 0x65, 0x71,
|
||||
0x75, 0x65, 0x73, 0x74, 0x12, 0x3a, 0x0a, 0x0a, 0x65, 0x78, 0x70, 0x69, 0x72, 0x61, 0x74, 0x69,
|
||||
0x6f, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c,
|
||||
0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73,
|
||||
0x74, 0x61, 0x6d, 0x70, 0x52, 0x0a, 0x65, 0x78, 0x70, 0x69, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e,
|
||||
0x22, 0x2f, 0x0a, 0x14, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x41, 0x70, 0x69, 0x4b, 0x65, 0x79,
|
||||
0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x17, 0x0a, 0x07, 0x61, 0x70, 0x69, 0x5f,
|
||||
0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x61, 0x70, 0x69, 0x4b, 0x65,
|
||||
0x79, 0x22, 0x2d, 0x0a, 0x13, 0x45, 0x78, 0x70, 0x69, 0x72, 0x65, 0x41, 0x70, 0x69, 0x4b, 0x65,
|
||||
0x79, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x16, 0x0a, 0x06, 0x70, 0x72, 0x65, 0x66,
|
||||
0x69, 0x78, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x70, 0x72, 0x65, 0x66, 0x69, 0x78,
|
||||
0x22, 0x16, 0x0a, 0x14, 0x45, 0x78, 0x70, 0x69, 0x72, 0x65, 0x41, 0x70, 0x69, 0x4b, 0x65, 0x79,
|
||||
0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x14, 0x0a, 0x12, 0x4c, 0x69, 0x73, 0x74,
|
||||
0x41, 0x70, 0x69, 0x4b, 0x65, 0x79, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x22, 0x46,
|
||||
0x0a, 0x13, 0x4c, 0x69, 0x73, 0x74, 0x41, 0x70, 0x69, 0x4b, 0x65, 0x79, 0x73, 0x52, 0x65, 0x73,
|
||||
0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x2f, 0x0a, 0x08, 0x61, 0x70, 0x69, 0x5f, 0x6b, 0x65, 0x79,
|
||||
0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x14, 0x2e, 0x68, 0x65, 0x61, 0x64, 0x73, 0x63,
|
||||
0x61, 0x6c, 0x65, 0x2e, 0x76, 0x31, 0x2e, 0x41, 0x70, 0x69, 0x4b, 0x65, 0x79, 0x52, 0x07, 0x61,
|
||||
0x70, 0x69, 0x4b, 0x65, 0x79, 0x73, 0x42, 0x29, 0x5a, 0x27, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62,
|
||||
0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x6a, 0x75, 0x61, 0x6e, 0x66, 0x6f, 0x6e, 0x74, 0x2f, 0x68, 0x65,
|
||||
0x61, 0x64, 0x73, 0x63, 0x61, 0x6c, 0x65, 0x2f, 0x67, 0x65, 0x6e, 0x2f, 0x67, 0x6f, 0x2f, 0x76,
|
||||
0x31, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
|
||||
}
|
||||
|
||||
var (
|
||||
file_headscale_v1_apikey_proto_rawDescOnce sync.Once
|
||||
file_headscale_v1_apikey_proto_rawDescData = file_headscale_v1_apikey_proto_rawDesc
|
||||
)
|
||||
|
||||
func file_headscale_v1_apikey_proto_rawDescGZIP() []byte {
|
||||
file_headscale_v1_apikey_proto_rawDescOnce.Do(func() {
|
||||
file_headscale_v1_apikey_proto_rawDescData = protoimpl.X.CompressGZIP(file_headscale_v1_apikey_proto_rawDescData)
|
||||
})
|
||||
return file_headscale_v1_apikey_proto_rawDescData
|
||||
}
|
||||
|
||||
var file_headscale_v1_apikey_proto_msgTypes = make([]protoimpl.MessageInfo, 7)
|
||||
var file_headscale_v1_apikey_proto_goTypes = []interface{}{
|
||||
(*ApiKey)(nil), // 0: headscale.v1.ApiKey
|
||||
(*CreateApiKeyRequest)(nil), // 1: headscale.v1.CreateApiKeyRequest
|
||||
(*CreateApiKeyResponse)(nil), // 2: headscale.v1.CreateApiKeyResponse
|
||||
(*ExpireApiKeyRequest)(nil), // 3: headscale.v1.ExpireApiKeyRequest
|
||||
(*ExpireApiKeyResponse)(nil), // 4: headscale.v1.ExpireApiKeyResponse
|
||||
(*ListApiKeysRequest)(nil), // 5: headscale.v1.ListApiKeysRequest
|
||||
(*ListApiKeysResponse)(nil), // 6: headscale.v1.ListApiKeysResponse
|
||||
(*timestamppb.Timestamp)(nil), // 7: google.protobuf.Timestamp
|
||||
}
|
||||
var file_headscale_v1_apikey_proto_depIdxs = []int32{
|
||||
7, // 0: headscale.v1.ApiKey.expiration:type_name -> google.protobuf.Timestamp
|
||||
7, // 1: headscale.v1.ApiKey.created_at:type_name -> google.protobuf.Timestamp
|
||||
7, // 2: headscale.v1.ApiKey.last_seen:type_name -> google.protobuf.Timestamp
|
||||
7, // 3: headscale.v1.CreateApiKeyRequest.expiration:type_name -> google.protobuf.Timestamp
|
||||
0, // 4: headscale.v1.ListApiKeysResponse.api_keys:type_name -> headscale.v1.ApiKey
|
||||
5, // [5:5] is the sub-list for method output_type
|
||||
5, // [5:5] is the sub-list for method input_type
|
||||
5, // [5:5] is the sub-list for extension type_name
|
||||
5, // [5:5] is the sub-list for extension extendee
|
||||
0, // [0:5] is the sub-list for field type_name
|
||||
}
|
||||
|
||||
func init() { file_headscale_v1_apikey_proto_init() }
|
||||
func file_headscale_v1_apikey_proto_init() {
|
||||
if File_headscale_v1_apikey_proto != nil {
|
||||
return
|
||||
}
|
||||
if !protoimpl.UnsafeEnabled {
|
||||
file_headscale_v1_apikey_proto_msgTypes[0].Exporter = func(v interface{}, i int) interface{} {
|
||||
switch v := v.(*ApiKey); i {
|
||||
case 0:
|
||||
return &v.state
|
||||
case 1:
|
||||
return &v.sizeCache
|
||||
case 2:
|
||||
return &v.unknownFields
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
file_headscale_v1_apikey_proto_msgTypes[1].Exporter = func(v interface{}, i int) interface{} {
|
||||
switch v := v.(*CreateApiKeyRequest); i {
|
||||
case 0:
|
||||
return &v.state
|
||||
case 1:
|
||||
return &v.sizeCache
|
||||
case 2:
|
||||
return &v.unknownFields
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
file_headscale_v1_apikey_proto_msgTypes[2].Exporter = func(v interface{}, i int) interface{} {
|
||||
switch v := v.(*CreateApiKeyResponse); i {
|
||||
case 0:
|
||||
return &v.state
|
||||
case 1:
|
||||
return &v.sizeCache
|
||||
case 2:
|
||||
return &v.unknownFields
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
file_headscale_v1_apikey_proto_msgTypes[3].Exporter = func(v interface{}, i int) interface{} {
|
||||
switch v := v.(*ExpireApiKeyRequest); i {
|
||||
case 0:
|
||||
return &v.state
|
||||
case 1:
|
||||
return &v.sizeCache
|
||||
case 2:
|
||||
return &v.unknownFields
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
file_headscale_v1_apikey_proto_msgTypes[4].Exporter = func(v interface{}, i int) interface{} {
|
||||
switch v := v.(*ExpireApiKeyResponse); i {
|
||||
case 0:
|
||||
return &v.state
|
||||
case 1:
|
||||
return &v.sizeCache
|
||||
case 2:
|
||||
return &v.unknownFields
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
file_headscale_v1_apikey_proto_msgTypes[5].Exporter = func(v interface{}, i int) interface{} {
|
||||
switch v := v.(*ListApiKeysRequest); i {
|
||||
case 0:
|
||||
return &v.state
|
||||
case 1:
|
||||
return &v.sizeCache
|
||||
case 2:
|
||||
return &v.unknownFields
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
file_headscale_v1_apikey_proto_msgTypes[6].Exporter = func(v interface{}, i int) interface{} {
|
||||
switch v := v.(*ListApiKeysResponse); i {
|
||||
case 0:
|
||||
return &v.state
|
||||
case 1:
|
||||
return &v.sizeCache
|
||||
case 2:
|
||||
return &v.unknownFields
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
type x struct{}
|
||||
out := protoimpl.TypeBuilder{
|
||||
File: protoimpl.DescBuilder{
|
||||
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
|
||||
RawDescriptor: file_headscale_v1_apikey_proto_rawDesc,
|
||||
NumEnums: 0,
|
||||
NumMessages: 7,
|
||||
NumExtensions: 0,
|
||||
NumServices: 0,
|
||||
},
|
||||
GoTypes: file_headscale_v1_apikey_proto_goTypes,
|
||||
DependencyIndexes: file_headscale_v1_apikey_proto_depIdxs,
|
||||
MessageInfos: file_headscale_v1_apikey_proto_msgTypes,
|
||||
}.Build()
|
||||
File_headscale_v1_apikey_proto = out.File
|
||||
file_headscale_v1_apikey_proto_rawDesc = nil
|
||||
file_headscale_v1_apikey_proto_goTypes = nil
|
||||
file_headscale_v1_apikey_proto_depIdxs = nil
|
||||
}
|
||||
1115
gen/go/headscale/v1/device.pb.go
Normal file
1115
gen/go/headscale/v1/device.pb.go
Normal file
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user