mirror of
https://github.com/juanfont/headscale.git
synced 2026-04-14 12:59:56 +02:00
Compare commits
2222 Commits
v0.10.7
...
v0.22.0-al
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
353f191e4f | ||
|
|
8d865bb61b | ||
|
|
c6815c5334 | ||
|
|
b684ac0668 | ||
|
|
dfc5d861c7 | ||
|
|
50b706eeed | ||
|
|
036ff1cbb9 | ||
|
|
ceeef40cdf | ||
|
|
681c86cc95 | ||
|
|
c7b459b615 | ||
|
|
56a7b1e349 | ||
|
|
f1eee841cb | ||
|
|
45fbd34480 | ||
|
|
248abcf353 | ||
|
|
2560c32378 | ||
|
|
e38efd3cfa | ||
|
|
d12f247490 | ||
|
|
003036a779 | ||
|
|
ed79f977a7 | ||
|
|
8012e1cbd2 | ||
|
|
a5562850a7 | ||
|
|
bb786ac8e4 | ||
|
|
ea82035222 | ||
|
|
c9ecdd6ef1 | ||
|
|
54f5c249f1 | ||
|
|
a82a603db6 | ||
|
|
f49930c514 | ||
|
|
2baeb79aa0 | ||
|
|
b3f78a209a | ||
|
|
5e6868a858 | ||
|
|
5caf848f94 | ||
|
|
3e097123bf | ||
|
|
74447b02e8 | ||
|
|
20e96de963 | ||
|
|
7c765fb3dc | ||
|
|
dcc246c869 | ||
|
|
cf7767d8f9 | ||
|
|
61c578f82b | ||
|
|
6950ff7841 | ||
|
|
e65ce17f7b | ||
|
|
b190ec8edc | ||
|
|
c39085911f | ||
|
|
3c20d2a178 | ||
|
|
9187e4287c | ||
|
|
2b7bcb77a5 | ||
|
|
97a909866d | ||
|
|
feeb5d334b | ||
|
|
a840a2e6ee | ||
|
|
4183345020 | ||
|
|
50fb7ad6ce | ||
|
|
88a9f4b44c | ||
|
|
00fbd8dd93 | ||
|
|
ce587d2421 | ||
|
|
e1eb30084d | ||
|
|
673638afe7 | ||
|
|
da48cf64b3 | ||
|
|
385fd93e73 | ||
|
|
26edf24477 | ||
|
|
83a538cc95 | ||
|
|
cffa040474 | ||
|
|
727d95b477 | ||
|
|
640bb94119 | ||
|
|
0f65918a25 | ||
|
|
3ac2e0b253 | ||
|
|
b322cdf251 | ||
|
|
e128796b59 | ||
|
|
6d669c6b9c | ||
|
|
8dadb045cf | ||
|
|
9f6e546522 | ||
|
|
9714900db9 | ||
|
|
cb25f0d650 | ||
|
|
9c2e580ab5 | ||
|
|
0ffff2c994 | ||
|
|
c720af66d6 | ||
|
|
86a7129027 | ||
|
|
9eaa8dd049 | ||
|
|
81441afe70 | ||
|
|
f19e8aa7f0 | ||
|
|
90287a6735 | ||
|
|
fb3e2dcf10 | ||
|
|
bf0b85f382 | ||
|
|
5da0963aac | ||
|
|
da5c051d73 | ||
|
|
b98bf199dd | ||
|
|
428d7c86ce | ||
|
|
af1ec5a593 | ||
|
|
e3a2593344 | ||
|
|
bafb6791d3 | ||
|
|
6edac4863a | ||
|
|
e27e01c09f | ||
|
|
dd173ecc1f | ||
|
|
8ca0fb7ed0 | ||
|
|
6c714e88ee | ||
|
|
a6c8718a97 | ||
|
|
26282b7a54 | ||
|
|
93aca81c1c | ||
|
|
81254cdf7a | ||
|
|
b3a0c4a63b | ||
|
|
376235c9de | ||
|
|
7274fdacc6 | ||
|
|
91c1f54b49 | ||
|
|
efd0f79fbc | ||
|
|
2084464225 | ||
|
|
66ebbf3ecb | ||
|
|
55a3885614 | ||
|
|
afae1ff7b6 | ||
|
|
4de49f5f49 | ||
|
|
6db9656008 | ||
|
|
fecb13b24b | ||
|
|
23a595c26f | ||
|
|
085912cfb4 | ||
|
|
7157e14aff | ||
|
|
4e2c4f92d3 | ||
|
|
893b0de8fa | ||
|
|
9b98c3b79f | ||
|
|
6de26b1d7c | ||
|
|
1f1931fb00 | ||
|
|
1f4efbcd3b | ||
|
|
711fe1d806 | ||
|
|
e2c62a7b0c | ||
|
|
ab6565723e | ||
|
|
7bb6f1a7eb | ||
|
|
549b82df11 | ||
|
|
036cdf922f | ||
|
|
b4ff22935c | ||
|
|
5feadbf3fc | ||
|
|
3e9ee816f9 | ||
|
|
2494e27a73 | ||
|
|
8e8b65bb84 | ||
|
|
b7d7fc57c4 | ||
|
|
b54c0e3d22 | ||
|
|
593040b73d | ||
|
|
6e890afc5f | ||
|
|
2afba0233b | ||
|
|
91900b7310 | ||
|
|
55b198a16a | ||
|
|
ca37dc6268 | ||
|
|
000c02dad9 | ||
|
|
4532915be1 | ||
|
|
4b8d6e7c64 | ||
|
|
579c5827b3 | ||
|
|
01628f76ff | ||
|
|
53858a32f1 | ||
|
|
2bf576ea8a | ||
|
|
1faac0b3d7 | ||
|
|
134c72f4fb | ||
|
|
70f2f5d750 | ||
|
|
4453728614 | ||
|
|
34107f9a0f | ||
|
|
52862b8a22 | ||
|
|
946d38e5d7 | ||
|
|
78819be03c | ||
|
|
34631dfcf5 | ||
|
|
8fa9755b55 | ||
|
|
1b557ac1ea | ||
|
|
8170f5e693 | ||
|
|
a506d0fcc8 | ||
|
|
6718ff71d3 | ||
|
|
b62acff2e3 | ||
|
|
ac8bff716d | ||
|
|
fba77de4eb | ||
|
|
d1bca105ef | ||
|
|
6c2d6fa302 | ||
|
|
1015bc3e02 | ||
|
|
68c72d03b5 | ||
|
|
bd4b2da06e | ||
|
|
7b8cf5ef1a | ||
|
|
638a3d48ec | ||
|
|
4de676c64e | ||
|
|
a58a552f0e | ||
|
|
0db16c7bbe | ||
|
|
06f7e7cfd8 | ||
|
|
19f12f94c0 | ||
|
|
95d3062c21 | ||
|
|
86fa136a63 | ||
|
|
89c12072ba | ||
|
|
54f701ff92 | ||
|
|
5a70ea7326 | ||
|
|
63cd3122e6 | ||
|
|
6f4c6c1876 | ||
|
|
eb072a1a74 | ||
|
|
36b8862e7c | ||
|
|
d4e3bf184b | ||
|
|
c28ca27133 | ||
|
|
c02e105065 | ||
|
|
22da5bfc1d | ||
|
|
c6d31747f7 | ||
|
|
91ed6e2197 | ||
|
|
d71aef3b98 | ||
|
|
8a79c2e7ed | ||
|
|
f34e7c341b | ||
|
|
e28d308796 | ||
|
|
f610be632e | ||
|
|
fd6d25b5c1 | ||
|
|
3695284286 | ||
|
|
cfaa36e51a | ||
|
|
d207c30949 | ||
|
|
519f22f9bf | ||
|
|
52a323b90d | ||
|
|
91559d0558 | ||
|
|
25195b8d73 | ||
|
|
e69176e200 | ||
|
|
d29d0222af | ||
|
|
72b9803a08 | ||
|
|
99e33181b2 | ||
|
|
e7f322b9b6 | ||
|
|
1d36e1775f | ||
|
|
0525bea593 | ||
|
|
2770c7cc07 | ||
|
|
1b0e80bb10 | ||
|
|
4ccc528d96 | ||
|
|
6a311f4ab6 | ||
|
|
a49a405413 | ||
|
|
24f946e2e9 | ||
|
|
c3cdb340de | ||
|
|
935319a218 | ||
|
|
4c7e15a7ce | ||
|
|
d461097247 | ||
|
|
f90a3c196c | ||
|
|
751cc173d4 | ||
|
|
ff134f2b8e | ||
|
|
6d3ede1367 | ||
|
|
c0884f94b8 | ||
|
|
3d8dd68b14 | ||
|
|
b02e88364e | ||
|
|
9790831afb | ||
|
|
2d79179141 | ||
|
|
275cc28193 | ||
|
|
c5ba7552c5 | ||
|
|
8909f801bb | ||
|
|
3d4af52b3a | ||
|
|
6391555dab | ||
|
|
8cc5b2174b | ||
|
|
9269dd01f5 | ||
|
|
ef68f17a96 | ||
|
|
f74266f8f8 | ||
|
|
46df219ed3 | ||
|
|
835288d864 | ||
|
|
93d56362af | ||
|
|
4799859be0 | ||
|
|
8e44596171 | ||
|
|
d479234058 | ||
|
|
3fc5866de0 | ||
|
|
f3c40086ac | ||
|
|
09ed21edd8 | ||
|
|
456479eaa1 | ||
|
|
cb87852825 | ||
|
|
69440058bb | ||
|
|
9bc6ac0f35 | ||
|
|
89ff5c83d2 | ||
|
|
0a47d694be | ||
|
|
73c84d4f6a | ||
|
|
a9251d6652 | ||
|
|
f9c44f11d6 | ||
|
|
1f8bd24a0d | ||
|
|
7bf2eb3d71 | ||
|
|
f5a5437917 | ||
|
|
9989657c0f | ||
|
|
cb2790984f | ||
|
|
18c0009a51 | ||
|
|
d038df2a88 | ||
|
|
d8e9d95a3b | ||
|
|
0e405c7ce0 | ||
|
|
21f0e089b6 | ||
|
|
cfda804726 | ||
|
|
d6b383dd2f | ||
|
|
07f92e647c | ||
|
|
bf87b33292 | ||
|
|
527b580f5e | ||
|
|
c31328a54a | ||
|
|
b2c0e37122 | ||
|
|
889223e35f | ||
|
|
6e83b7f06b | ||
|
|
31d427b655 | ||
|
|
d8c856e602 | ||
|
|
aad4c90fe6 | ||
|
|
4f9fe93146 | ||
|
|
96fe6aa3a1 | ||
|
|
947e961a3a | ||
|
|
43731cad2e | ||
|
|
ac15b21720 | ||
|
|
dfc03a6124 | ||
|
|
8a07381e3a | ||
|
|
0cf9c4ce8e | ||
|
|
e8b3de494e | ||
|
|
21ec543d37 | ||
|
|
ca8bca98ed | ||
|
|
4e8b95e6cd | ||
|
|
ad31378d92 | ||
|
|
3a6257b193 | ||
|
|
fafa3f8211 | ||
|
|
62e3fa0011 | ||
|
|
94ad0a1555 | ||
|
|
c1c22a4b51 | ||
|
|
611f7c374c | ||
|
|
91c0a153b0 | ||
|
|
73eae8e2cf | ||
|
|
341db0c5c9 | ||
|
|
2ca286ee8c | ||
|
|
dde39aa24c | ||
|
|
bcdd34b01e | ||
|
|
e45ba37ec5 | ||
|
|
d69a5f621e | ||
|
|
7f69b08bc8 | ||
|
|
5d3c02702b | ||
|
|
1469425484 | ||
|
|
0e12b66706 | ||
|
|
7e6ab19270 | ||
|
|
5013187aaf | ||
|
|
239ef16ad1 | ||
|
|
cb61a490e0 | ||
|
|
2c0488da0b | ||
|
|
a647e6af24 | ||
|
|
fe4e05b0bc | ||
|
|
54e3a0d372 | ||
|
|
e7e2c7804b | ||
|
|
5c9c4f27fe | ||
|
|
21b06f603a | ||
|
|
a14f482ef7 | ||
|
|
86c132c8b2 | ||
|
|
2b10226618 | ||
|
|
23a0946e76 | ||
|
|
7015d72911 | ||
|
|
76689c221d | ||
|
|
8d46986a87 | ||
|
|
b22e628b49 | ||
|
|
9c30939e3f | ||
|
|
018b1d68f2 | ||
|
|
ae189c03ac | ||
|
|
7155b22043 | ||
|
|
40c048fb45 | ||
|
|
53b4bb220d | ||
|
|
d706c3516d | ||
|
|
cbbf9fbdef | ||
|
|
d8144ee2ed | ||
|
|
fa3d21cbc0 | ||
|
|
d242ceac46 | ||
|
|
ecce82d44a | ||
|
|
463180cc2e | ||
|
|
129afdb157 | ||
|
|
701f990a23 | ||
|
|
e112514a3b | ||
|
|
babd303667 | ||
|
|
2d170fe339 | ||
|
|
bc1c1f5ce8 | ||
|
|
830d59fe8c | ||
|
|
c9823ce347 | ||
|
|
8c4744acd9 | ||
|
|
9c16d5e511 | ||
|
|
40b3de9894 | ||
|
|
1eea9c943c | ||
|
|
399c3255ab | ||
|
|
852cb90fcc | ||
|
|
587a016b46 | ||
|
|
b2bca2ac81 | ||
|
|
6d8c18d4de | ||
|
|
12ee9bc02d | ||
|
|
8502a0acda | ||
|
|
36ad0003a9 | ||
|
|
4cb7d63e8b | ||
|
|
2bf50bc205 | ||
|
|
39bc6f7e01 | ||
|
|
0db608a7b7 | ||
|
|
3951f39868 | ||
|
|
c90d0dd843 | ||
|
|
84f9f604b0 | ||
|
|
aef77a113c | ||
|
|
13aa845c69 | ||
|
|
b0a4ee4dfe | ||
|
|
25e39d9ff9 | ||
|
|
f109b54e79 | ||
|
|
eda4321486 | ||
|
|
a9c3b14f79 | ||
|
|
f68ba7504f | ||
|
|
b331e3f736 | ||
|
|
308b9e78a1 | ||
|
|
fa8b02a83f | ||
|
|
a39504510a | ||
|
|
2f36a11a8e | ||
|
|
4df47de3f2 | ||
|
|
dfadb965b7 | ||
|
|
c6f82c3646 | ||
|
|
32c21a05f8 | ||
|
|
79864e0165 | ||
|
|
06e12f7020 | ||
|
|
3659461666 | ||
|
|
e96bceed4c | ||
|
|
ff217ccce8 | ||
|
|
4dd2eef5d1 | ||
|
|
907aa07e51 | ||
|
|
0048ed07a2 | ||
|
|
88d12873c5 | ||
|
|
9f58eebfe1 | ||
|
|
cf40d2a892 | ||
|
|
21dd212349 | ||
|
|
073308f1a3 | ||
|
|
03194e2d66 | ||
|
|
f18e22224c | ||
|
|
8ee35c9c22 | ||
|
|
d900f48d38 | ||
|
|
a846e13c78 | ||
|
|
ed2236aa24 | ||
|
|
a94ed0586e | ||
|
|
22cabc16d7 | ||
|
|
88931001fd | ||
|
|
f3dbfc9045 | ||
|
|
85df2c80a8 | ||
|
|
aca3a667c4 | ||
|
|
a0ec3690b6 | ||
|
|
37a4d41d0e | ||
|
|
382a37f1e1 | ||
|
|
201f81ce00 | ||
|
|
4904ccc3c3 | ||
|
|
6b67584d47 | ||
|
|
d575dac73a | ||
|
|
5333df283a | ||
|
|
d56ad2917d | ||
|
|
df36bcfd39 | ||
|
|
a3d3ad2208 | ||
|
|
0b0fb0af22 | ||
|
|
2aebd2927d | ||
|
|
c00e5599b0 | ||
|
|
72e2fa46c7 | ||
|
|
98f5b7f638 | ||
|
|
70ecda6fd1 | ||
|
|
5fe6538c02 | ||
|
|
84c4b0336f | ||
|
|
8fbba1ac94 | ||
|
|
1a30bcba91 | ||
|
|
ed58b2e4e2 | ||
|
|
5f975cbb50 | ||
|
|
81dd9b2386 | ||
|
|
9088521252 | ||
|
|
fc6a1e15fc | ||
|
|
94be5ca295 | ||
|
|
804d9d8196 | ||
|
|
d0e945fdd7 | ||
|
|
98e7842c26 | ||
|
|
24629895c7 | ||
|
|
256b6cb54d | ||
|
|
6b4d53315b | ||
|
|
fb25a06a66 | ||
|
|
dbe58e53e4 | ||
|
|
8dcc82ceb3 | ||
|
|
8be14ef6fe | ||
|
|
2bb34751d1 | ||
|
|
d06ba7b522 | ||
|
|
a507a04650 | ||
|
|
7761a7b23e | ||
|
|
6d2cfd52c5 | ||
|
|
75a8fc8b3e | ||
|
|
8fa05c1e72 | ||
|
|
93082b8092 | ||
|
|
d764f52f24 | ||
|
|
e5decbd0fa | ||
|
|
8a1c0e0e9b | ||
|
|
5b12ab9894 | ||
|
|
c52e3aafe6 | ||
|
|
a46170e2a1 | ||
|
|
aca1c1b156 | ||
|
|
09863b540d | ||
|
|
adb352e663 | ||
|
|
c9b39da6b9 | ||
|
|
6fe86dff00 | ||
|
|
9b1dcb2f0c | ||
|
|
22c68fff13 | ||
|
|
ddd92822b0 | ||
|
|
bd6282d1e3 | ||
|
|
7092a3ea47 | ||
|
|
695359862e | ||
|
|
95948e03c9 | ||
|
|
e286ba817b | ||
|
|
8aa0eefedd | ||
|
|
e6e5872b4b | ||
|
|
2c73f8ee62 | ||
|
|
cdc8bab7d9 | ||
|
|
f2928d7dcb | ||
|
|
44be239723 | ||
|
|
397754753f | ||
|
|
e87b470996 | ||
|
|
083d2a871c | ||
|
|
7a171cf5ea | ||
|
|
1563d7555f | ||
|
|
2e97119db8 | ||
|
|
b3a53bf642 | ||
|
|
a3f18f248c | ||
|
|
1c267f72e0 | ||
|
|
becf918b78 | ||
|
|
9c58395bb3 | ||
|
|
b117ca7720 | ||
|
|
d83a28bd1b | ||
|
|
42ef71bff9 | ||
|
|
f2da1a1665 | ||
|
|
356b76fc56 | ||
|
|
33ae56acfa | ||
|
|
9923adcb8b | ||
|
|
c21479cb9c | ||
|
|
3abca99b0c | ||
|
|
874d6aaf6b | ||
|
|
ae4f2cc4b5 | ||
|
|
dd155dca97 | ||
|
|
99307d1576 | ||
|
|
b2f3ffbc5a | ||
|
|
5774b32e55 | ||
|
|
41353a57c8 | ||
|
|
9c0cf4595a | ||
|
|
71b712356f | ||
|
|
f33e3e3b81 | ||
|
|
5f384c6323 | ||
|
|
e056b86c37 | ||
|
|
91e30397bd | ||
|
|
8a8ec7476d | ||
|
|
fca380587a | ||
|
|
cb70d7c705 | ||
|
|
b27b789e28 | ||
|
|
a9da953b55 | ||
|
|
12d5b6a2d2 | ||
|
|
a0a463494b | ||
|
|
07dca79b20 | ||
|
|
688cba7292 | ||
|
|
0fe3c21223 | ||
|
|
45df6e77ff | ||
|
|
548551c6ae | ||
|
|
e3f1fd1ffc | ||
|
|
470c49394c | ||
|
|
31662bcd28 | ||
|
|
7247302f45 | ||
|
|
1a5a5b12b7 | ||
|
|
0099dd1724 | ||
|
|
1f131c6729 | ||
|
|
fc4361b225 | ||
|
|
ce25a1e64e | ||
|
|
449a135b94 | ||
|
|
002d484abe | ||
|
|
9823ef2af5 | ||
|
|
641c6fd439 | ||
|
|
3a042471b7 | ||
|
|
dc18d64286 | ||
|
|
72a43007d8 | ||
|
|
842c28adff | ||
|
|
9810d84e2d | ||
|
|
f6153a9b5d | ||
|
|
302a88bfdb | ||
|
|
f6e83413e5 | ||
|
|
02ab3a2cb6 | ||
|
|
90e840c3c9 | ||
|
|
af60ffb7fa | ||
|
|
c28e559da4 | ||
|
|
5c59255b41 | ||
|
|
a377ee14b4 | ||
|
|
2262188d8a | ||
|
|
7c49c752a9 | ||
|
|
e29726cc50 | ||
|
|
3c73cbe92b | ||
|
|
cc357062be | ||
|
|
17c06f7167 | ||
|
|
d12e0156c3 | ||
|
|
204dedaa49 | ||
|
|
52073ce7c9 | ||
|
|
434747e007 | ||
|
|
7a78314d9d | ||
|
|
f23e9dc235 | ||
|
|
4527801d48 | ||
|
|
e0857f0226 | ||
|
|
0d074b1da6 | ||
|
|
f2fda4f906 | ||
|
|
c1c36036ae | ||
|
|
9a1438d2e3 | ||
|
|
582122851d | ||
|
|
f4d197485c | ||
|
|
68305df9b2 | ||
|
|
ca0be81833 | ||
|
|
380fbfe438 | ||
|
|
32d68a40d5 | ||
|
|
198e92c08f | ||
|
|
38b26f5285 | ||
|
|
096a009685 | ||
|
|
30c0fdb38d | ||
|
|
663dbf7395 | ||
|
|
373db0dc5e | ||
|
|
2733fb30cc | ||
|
|
d29411408b | ||
|
|
24bafdf2bb | ||
|
|
a9ede6a2bc | ||
|
|
2c5bf6982c | ||
|
|
dd3ec84000 | ||
|
|
84044e236d | ||
|
|
2ddf7ab515 | ||
|
|
f519c513c2 | ||
|
|
d5cc5b2bc8 | ||
|
|
51abf90db6 | ||
|
|
71410cb6da | ||
|
|
efb12f208c | ||
|
|
64ede5dbef | ||
|
|
7af78152a4 | ||
|
|
290ec8bb19 | ||
|
|
cdf48b1216 | ||
|
|
a24710a961 | ||
|
|
197da8afcb | ||
|
|
12385d4357 | ||
|
|
e7f8bb866f | ||
|
|
1ad19a3bd8 | ||
|
|
bb6b07dedc | ||
|
|
2403c0e198 | ||
|
|
ac18723dd4 | ||
|
|
6faa1d2e4a | ||
|
|
791272e408 | ||
|
|
e27a4db281 | ||
|
|
60cc9ddb3b | ||
|
|
7653ad40d6 | ||
|
|
004ebcaba1 | ||
|
|
cc0bec15ef | ||
|
|
20970b580a | ||
|
|
53857d418a | ||
|
|
a81a4d274f | ||
|
|
ce4a1cf447 | ||
|
|
35dd9209b9 | ||
|
|
81f91f03b4 | ||
|
|
84a5edf345 | ||
|
|
4aafe6c9d1 | ||
|
|
3ab1487641 | ||
|
|
0c7f1eac82 | ||
|
|
6fe895fd22 | ||
|
|
71d22dc994 | ||
|
|
4424a9abc0 | ||
|
|
e20e818a42 | ||
|
|
061e2fe4b4 | ||
|
|
f0a8a2857b | ||
|
|
175dfa1ede | ||
|
|
04e4fa785b | ||
|
|
6aec520889 | ||
|
|
e9906b522f | ||
|
|
2f554133c5 | ||
|
|
922b8b5365 | ||
|
|
c894db3dd4 | ||
|
|
e85562268d | ||
|
|
fca33aacbe | ||
|
|
e43713a866 | ||
|
|
b6e3cd81c6 | ||
|
|
43ad0d4416 | ||
|
|
a33b5a5c00 | ||
|
|
e2bffd4f5a | ||
|
|
a87a9636e3 | ||
|
|
a31432ee7b | ||
|
|
0c66590108 | ||
|
|
c6ea9b4b80 | ||
|
|
19455399f4 | ||
|
|
43ba1fb176 | ||
|
|
a6f56b4285 | ||
|
|
9d430d3c72 | ||
|
|
f9a2a2b57a | ||
|
|
e4d961cfad | ||
|
|
67ffebc30a | ||
|
|
cf731fafab | ||
|
|
f43a83aad7 | ||
|
|
7185f8dfea | ||
|
|
8a707de5f1 | ||
|
|
61bb6292b7 | ||
|
|
40e0ae99da | ||
|
|
2dd615a4ef | ||
|
|
7e06abdca2 | ||
|
|
c316f53e23 | ||
|
|
b6d324be69 | ||
|
|
f7380312d3 | ||
|
|
287309b65c | ||
|
|
cc3de7e723 | ||
|
|
e03b3029e3 | ||
|
|
ba07bac46a | ||
|
|
b71a881d0e | ||
|
|
ce53bb0eee | ||
|
|
c0fe1abf4d | ||
|
|
0db7fc5ab7 | ||
|
|
701ad3e017 | ||
|
|
0cc14d0aca | ||
|
|
3f5ea7998f | ||
|
|
4c7f54020b | ||
|
|
eb461d0713 | ||
|
|
128ec6717c | ||
|
|
b3cf5289f8 | ||
|
|
c701f9e817 | ||
|
|
e1a95e2057 | ||
|
|
0a5db52855 | ||
|
|
7197ade4b4 | ||
|
|
865f1ffb3c | ||
|
|
8db7629edf | ||
|
|
b8980b9ed3 | ||
|
|
5cf9eedf42 | ||
|
|
193b4213b3 | ||
|
|
8557bcedae | ||
|
|
f599bea216 | ||
|
|
704a19b0a5 | ||
|
|
e29b344e0f | ||
|
|
7cc227d01e | ||
|
|
df8ecdb603 | ||
|
|
f4bab6b290 | ||
|
|
35f3dee1d0 | ||
|
|
db89fdea23 | ||
|
|
d0898ecabc | ||
|
|
e640c6df05 | ||
|
|
ab18c721bb | ||
|
|
aaa33cf093 | ||
|
|
0f09e19e38 | ||
|
|
b301405f24 | ||
|
|
1f3032ad21 | ||
|
|
c10142f767 | ||
|
|
0d0042b7e6 | ||
|
|
78a179c971 | ||
|
|
cab828c9d4 | ||
|
|
ff46f3ff49 | ||
|
|
b67cff50f5 | ||
|
|
e29ac8a4ab | ||
|
|
20d2615081 | ||
|
|
7fb2f83540 | ||
|
|
eb8d8f142c | ||
|
|
3bea20850a | ||
|
|
ade1b73779 | ||
|
|
281ae59b5a | ||
|
|
90bb6ea907 | ||
|
|
5b14cafddd | ||
|
|
9994fce9d5 | ||
|
|
c19e1a481e | ||
|
|
39b85b02bb | ||
|
|
7a91c82cda | ||
|
|
c7cea9ef16 | ||
|
|
d56b409cb9 | ||
|
|
ee8f38111e | ||
|
|
8c13f64d3c | ||
|
|
a7efc22045 | ||
|
|
1880035f6f | ||
|
|
fdd0c50402 | ||
|
|
be24bacb79 | ||
|
|
b261d19cfe | ||
|
|
ec5acf7be2 | ||
|
|
014e7abc68 | ||
|
|
3e8f0e9984 | ||
|
|
6e8e2bf508 | ||
|
|
09cd7ba304 | ||
|
|
77bf1e81ec | ||
|
|
a9b9a2942d | ||
|
|
a261e27113 | ||
|
|
f01a33491b | ||
|
|
739e11e1ee | ||
|
|
393aae01df | ||
|
|
73cd428ed2 | ||
|
|
1e7b57e513 | ||
|
|
e1e3feb6a8 | ||
|
|
8e56d8b425 | ||
|
|
6c8445988c | ||
|
|
110b01befa | ||
|
|
d586b9d285 | ||
|
|
804d70386d | ||
|
|
fb3b2e6bc8 | ||
|
|
030d7264e6 | ||
|
|
e91c378bd4 | ||
|
|
e950b3be29 | ||
|
|
dbf0e206b8 | ||
|
|
84f66090fd | ||
|
|
f8958d4e22 | ||
|
|
70807e40f6 | ||
|
|
9a01e3d192 | ||
|
|
a03a99569d | ||
|
|
2d887046de | ||
|
|
3a091896fb | ||
|
|
8a9fe1da4b | ||
|
|
abf478c9e6 | ||
|
|
913a94d2ab | ||
|
|
01e5be3b57 | ||
|
|
e93529e9f3 | ||
|
|
ade4e23e14 | ||
|
|
fc65ded2d5 | ||
|
|
aa2b92703f | ||
|
|
2c9dbe158d | ||
|
|
d6fa5c96ae | ||
|
|
0506e68a96 | ||
|
|
b32f986105 | ||
|
|
577eedef11 | ||
|
|
27855880b2 | ||
|
|
b01d392f9e | ||
|
|
d548f5de3f | ||
|
|
f8986132d4 | ||
|
|
e7148b8080 | ||
|
|
0a29492fc5 | ||
|
|
a1e7e771ce | ||
|
|
00d2a447f4 | ||
|
|
2254ac2102 | ||
|
|
21ae31e77d | ||
|
|
a6113066ff | ||
|
|
0bb205d31f | ||
|
|
d7e8db7adc | ||
|
|
0eb3b23f16 | ||
|
|
54e381cecb | ||
|
|
cc1343d31d | ||
|
|
bce59345e4 | ||
|
|
79688e6187 | ||
|
|
babf9470c2 | ||
|
|
10d566c946 | ||
|
|
911e6ba6de | ||
|
|
f9c4d577e2 | ||
|
|
9826b518bd | ||
|
|
32a8f06486 | ||
|
|
2ab2b8656b | ||
|
|
d9ab98e47f | ||
|
|
9d584bb0d3 | ||
|
|
4f725ba9e1 | ||
|
|
b75a113c91 | ||
|
|
75af83bb81 | ||
|
|
0f6f0c3b6b | ||
|
|
b344524a6d | ||
|
|
6f4d5a532e | ||
|
|
2d83c70173 | ||
|
|
c90e862460 | ||
|
|
c46a34e6b8 | ||
|
|
693f59ba2f | ||
|
|
abae078855 | ||
|
|
0212db3fad | ||
|
|
49354f678e | ||
|
|
dc94570c4a | ||
|
|
51b1027aec | ||
|
|
936adb7d2c | ||
|
|
581d1f3bfa | ||
|
|
7c87ef6c86 | ||
|
|
1a9a9b718d | ||
|
|
6c9f3420e2 | ||
|
|
a4d0efbe8d | ||
|
|
56858a56db | ||
|
|
395caaad42 | ||
|
|
3f0639c87d | ||
|
|
889eff265f | ||
|
|
c6eb7be7fb | ||
|
|
02c7a46b97 | ||
|
|
ea7b3baa8b | ||
|
|
5724f4607c | ||
|
|
b755d47652 | ||
|
|
96221cc4f7 | ||
|
|
34d261179e | ||
|
|
091b05f155 | ||
|
|
aca5646032 | ||
|
|
7e9abbeaec | ||
|
|
c6aaa37f2d | ||
|
|
b8c3387892 | ||
|
|
c50d3aa9bd | ||
|
|
4ccff8bf28 | ||
|
|
5b5298b025 | ||
|
|
8e0939f403 | ||
|
|
cf3fc85196 | ||
|
|
e0b15c18ce | ||
|
|
566b8c3df3 | ||
|
|
32a6151df9 | ||
|
|
3777de7133 | ||
|
|
8cae4f80d7 | ||
|
|
911c5bddce | ||
|
|
4a200c308b | ||
|
|
625e45b1cb | ||
|
|
8551b0dde0 | ||
|
|
050782aff3 | ||
|
|
00885dffe1 | ||
|
|
ffcc72876c | ||
|
|
fa91ece5b4 | ||
|
|
c810b24eb9 | ||
|
|
03ced0ecfe | ||
|
|
c859bea0cf | ||
|
|
a913d1b521 | ||
|
|
2464c92572 | ||
|
|
10cd87e5a2 | ||
|
|
58c336e7f4 | ||
|
|
bb4a9583a7 | ||
|
|
7ae38346e5 | ||
|
|
7604c0f691 | ||
|
|
f2f4c3f684 | ||
|
|
34f489b1f4 | ||
|
|
72d1d2630e | ||
|
|
d559e23bc6 | ||
|
|
4637400d29 | ||
|
|
0fa943e4b7 | ||
|
|
9707b1f540 | ||
|
|
657fb208d6 | ||
|
|
647972c7cf | ||
|
|
39b58f7d4c | ||
|
|
c8378e8b7d | ||
|
|
d404ba102d | ||
|
|
5e9004c407 | ||
|
|
8e63b53b0c | ||
|
|
116bef25a7 | ||
|
|
294975ba87 | ||
|
|
51b8c659f1 | ||
|
|
082fbead66 | ||
|
|
73c16ffc65 | ||
|
|
dec51348e6 | ||
|
|
b0b919efb0 | ||
|
|
396c3ecdf7 | ||
|
|
53e5c05b0a | ||
|
|
dedeb4c181 | ||
|
|
e611063669 | ||
|
|
6c9c9a401f | ||
|
|
6da4396faa | ||
|
|
d89fb68a7a | ||
|
|
8d9462147c | ||
|
|
89b7fa6b06 | ||
|
|
d4a550bb4c | ||
|
|
d5e331a2fb | ||
|
|
367da0fcc2 | ||
|
|
8111b0aa83 | ||
|
|
735440d1a3 | ||
|
|
3ae340527f | ||
|
|
bfa9ed814d | ||
|
|
1e4678c02f | ||
|
|
66fffd69ce | ||
|
|
e3f99d670e | ||
|
|
360488abb4 | ||
|
|
8dda44105e | ||
|
|
2215e17223 | ||
|
|
157db307f9 | ||
|
|
0bd39b2c5e | ||
|
|
8f31ed51e1 | ||
|
|
d2d1f92836 | ||
|
|
c02819ab9f | ||
|
|
28a3a5bd61 | ||
|
|
891815634b | ||
|
|
8650328922 | ||
|
|
7bd07e3b9b | ||
|
|
76195bb3ac | ||
|
|
6afd492095 | ||
|
|
c95bce4aea | ||
|
|
fd3a1c13e3 | ||
|
|
95824ac2ec | ||
|
|
a050158d11 | ||
|
|
e0ef601123 | ||
|
|
9c5d485fdd | ||
|
|
cb88b16207 | ||
|
|
257c025975 | ||
|
|
50bdf9d3b9 | ||
|
|
8d58894daa | ||
|
|
43fa7f9fd5 | ||
|
|
f2a8bfeb9f | ||
|
|
06bbeea37f | ||
|
|
e5f26f819a | ||
|
|
a058f17946 | ||
|
|
a4b4fc8b6c | ||
|
|
ab35baaa29 | ||
|
|
883bb92991 | ||
|
|
bfb58de7b8 | ||
|
|
6faf2d63d0 | ||
|
|
569f3caab9 | ||
|
|
7cd0f5e8a4 | ||
|
|
02cc6bcc05 | ||
|
|
9ff09b73ad | ||
|
|
f93cf4b980 | ||
|
|
3d7be5b287 | ||
|
|
cdf41bd500 | ||
|
|
735a6aaa39 | ||
|
|
0c2648c188 | ||
|
|
7e6291c21c | ||
|
|
3f7749c6d4 | ||
|
|
586c5411f1 | ||
|
|
2be16b581c | ||
|
|
06e22bf878 | ||
|
|
0b4b530809 | ||
|
|
efca3daa5c | ||
|
|
fdefe46c40 | ||
|
|
34be10840c | ||
|
|
80ad1db228 | ||
|
|
e918ea89a3 | ||
|
|
19b968849f | ||
|
|
5bc11891f5 | ||
|
|
818d26b5f9 | ||
|
|
c47354bdc3 | ||
|
|
86ce0e0c66 | ||
|
|
39f03b86c8 | ||
|
|
8287ba24b9 | ||
|
|
ab1aac9f3e | ||
|
|
3e353004b8 | ||
|
|
bcb04d38a5 | ||
|
|
de0e2bf828 | ||
|
|
8fed47a2be | ||
|
|
17d4968425 | ||
|
|
54acee6880 | ||
|
|
a4e05d4db3 | ||
|
|
b0acbed329 | ||
|
|
1b2967320b | ||
|
|
90f6be0c98 | ||
|
|
78ed610b50 | ||
|
|
af891808f6 | ||
|
|
0c5a402206 | ||
|
|
8744eeeb19 | ||
|
|
ce13596077 | ||
|
|
402a29e50c | ||
|
|
0363e58467 | ||
|
|
c8a14ccabb | ||
|
|
1de29fd4e6 | ||
|
|
75a0155f73 | ||
|
|
adb55bcfe9 | ||
|
|
2201ec8905 | ||
|
|
39f6fdef1a | ||
|
|
699aa5cf38 | ||
|
|
1486adb25a | ||
|
|
2653c2f5e8 | ||
|
|
7b7244dac2 | ||
|
|
571ce2b0b9 | ||
|
|
c3db5ed749 | ||
|
|
0797148076 | ||
|
|
24c9530eee | ||
|
|
679cf7c0d7 | ||
|
|
19b6405332 | ||
|
|
aee8aa1c61 | ||
|
|
5514a862dc | ||
|
|
1ea8bb782c | ||
|
|
35722cd5aa | ||
|
|
533ecee252 | ||
|
|
f1db2d0c8e | ||
|
|
6f6fb4dcd6 | ||
|
|
b1ba7ba685 | ||
|
|
6dccfee862 | ||
|
|
6f32b80b2b | ||
|
|
2feed18b28 | ||
|
|
36dca3516a | ||
|
|
06129277ed | ||
|
|
6b1482daee | ||
|
|
24e4787a64 | ||
|
|
5bfae22c8f | ||
|
|
3e078f0494 | ||
|
|
0b4f59b82b | ||
|
|
a19af04582 | ||
|
|
0676aa11a9 | ||
|
|
be25bbce92 | ||
|
|
5ecfbbaf5d | ||
|
|
7f7cd737dc | ||
|
|
b472e5a689 | ||
|
|
25c674ed32 | ||
|
|
3d93cf9e2d | ||
|
|
f7edea5f40 | ||
|
|
d26e220fb9 | ||
|
|
d860270733 | ||
|
|
a09633e859 | ||
|
|
a1837a4d69 | ||
|
|
52cc3bc8eb | ||
|
|
9175aca094 | ||
|
|
848727a21d | ||
|
|
df7d5fa2b9 | ||
|
|
86dfc91dd5 | ||
|
|
7f66d9184b | ||
|
|
ff5f31b87e | ||
|
|
a0c465c2eb | ||
|
|
d11279e615 | ||
|
|
266aac9e61 | ||
|
|
4ffd3eacb0 | ||
|
|
a443255b3e | ||
|
|
a992840c9b | ||
|
|
dbc1d981c9 | ||
|
|
9993f51b5e | ||
|
|
3a3fc0a4be | ||
|
|
5316dd9c27 | ||
|
|
59a1a85a2b | ||
|
|
fc502e1e79 | ||
|
|
405de9e0f8 | ||
|
|
6eac5046c6 | ||
|
|
f7f722af52 | ||
|
|
583f6eeedd | ||
|
|
bec35b4965 | ||
|
|
e596d8287c | ||
|
|
6c903d2d93 | ||
|
|
914431b94a | ||
|
|
11da7436c7 | ||
|
|
0f532aa5c1 | ||
|
|
835828fe92 | ||
|
|
fff1011ed8 | ||
|
|
ef497caa1b | ||
|
|
4f3f0542d4 | ||
|
|
5fa987519d | ||
|
|
77ceeaf5fd | ||
|
|
4a9d3bedf9 | ||
|
|
802eb931d1 | ||
|
|
9ebeb3d7e4 | ||
|
|
e631c6f7e0 | ||
|
|
163e5c29e4 | ||
|
|
4aae917f74 | ||
|
|
9b393eb861 | ||
|
|
5fa3016703 | ||
|
|
03cccd60a6 | ||
|
|
177c21b294 | ||
|
|
f4873d9387 | ||
|
|
747d64cdae | ||
|
|
c9efd5c132 | ||
|
|
546ddd2a84 | ||
|
|
2edb5428f9 | ||
|
|
9f082125fa | ||
|
|
11582105ab | ||
|
|
c4e69fe2c3 | ||
|
|
4435a4f19d | ||
|
|
02ae7a0563 | ||
|
|
852dc0f4de | ||
|
|
844ad15109 | ||
|
|
522e892099 | ||
|
|
0445f404ec | ||
|
|
bc1909fa22 | ||
|
|
ca71830963 | ||
|
|
a28eebfca3 | ||
|
|
0d31ea08c3 | ||
|
|
614c003704 | ||
|
|
b511295349 | ||
|
|
fcdc292647 | ||
|
|
09836cd150 | ||
|
|
49ec9943b9 | ||
|
|
72c1edaaa4 | ||
|
|
294ed7a751 | ||
|
|
31c0062d5e | ||
|
|
63d920510d | ||
|
|
16f9691e80 | ||
|
|
209d003832 | ||
|
|
62cfd60e38 | ||
|
|
fdbc9657bc | ||
|
|
ad4401aa40 | ||
|
|
c26280c331 | ||
|
|
b028a7dfc9 | ||
|
|
41cd0d30eb | ||
|
|
8be9e9655c | ||
|
|
31bdba7456 | ||
|
|
d6e1d10b12 | ||
|
|
21268f7abe | ||
|
|
91b95ff707 | ||
|
|
6ed79b7bb8 | ||
|
|
b4f5ed6618 | ||
|
|
ed46491a3d | ||
|
|
dc8c20e002 | ||
|
|
68417cc888 | ||
|
|
a2fb5b2b9d | ||
|
|
3fbfc5a649 | ||
|
|
00535a2016 | ||
|
|
fd452d52ca | ||
|
|
7cc58af932 | ||
|
|
ddb87af5ce | ||
|
|
b9ea83fed8 | ||
|
|
e279224484 | ||
|
|
12d8f0f4b0 | ||
|
|
6ba68d150c | ||
|
|
1b3a7bbf03 | ||
|
|
4e686f8b77 | ||
|
|
62c780a448 | ||
|
|
bc055edf12 | ||
|
|
47c72a4e2e | ||
|
|
02a78e5a45 | ||
|
|
01d9a2f589 | ||
|
|
5403f215bc | ||
|
|
96e2955ba7 | ||
|
|
03659c4175 | ||
|
|
843e2bd9b6 | ||
|
|
28efd92fca | ||
|
|
7bb87a7300 | ||
|
|
fec8cda16a | ||
|
|
2c448d4a5c | ||
|
|
3d302441b6 | ||
|
|
8061abe279 | ||
|
|
ea9aaa6022 | ||
|
|
cc9eeda889 | ||
|
|
25f1dcf724 | ||
|
|
31debf7055 | ||
|
|
db8db0299e | ||
|
|
e80954b6c8 | ||
|
|
8504d0d8ba | ||
|
|
7ef8cd881c | ||
|
|
79704dc9b0 | ||
|
|
06c928bc52 | ||
|
|
62808cbd86 | ||
|
|
14994cb6cc | ||
|
|
6b79679cb4 | ||
|
|
caf79f6910 | ||
|
|
6e2768097a | ||
|
|
8845938881 | ||
|
|
a23035aee7 | ||
|
|
e51e6f487f | ||
|
|
f78deaebb6 | ||
|
|
4d2949bda9 | ||
|
|
cb0899b534 | ||
|
|
ecf5259693 | ||
|
|
3a90079ab8 | ||
|
|
970dea5d68 | ||
|
|
cd9807a1d3 | ||
|
|
613dc61339 | ||
|
|
b9fee36f6e | ||
|
|
17d6624bb9 | ||
|
|
f53bb63b2d | ||
|
|
ea7bcfffbb | ||
|
|
3023323528 | ||
|
|
2dfd8a9098 | ||
|
|
c8ed1f0f43 | ||
|
|
f9e2ce2c8c | ||
|
|
886e95c00d | ||
|
|
6dd9e93346 | ||
|
|
2dacf839dc | ||
|
|
8f6952acee | ||
|
|
235a90276f | ||
|
|
5c285afda5 | ||
|
|
db930af50e | ||
|
|
ffa570e877 | ||
|
|
96ae78f422 | ||
|
|
580c72bf16 | ||
|
|
9254afff2d | ||
|
|
7ce0bd053c | ||
|
|
41a8c14acb | ||
|
|
be2487f4c0 | ||
|
|
4651c44dde | ||
|
|
4fcc5e253c | ||
|
|
89a1a56328 | ||
|
|
db1528bc73 | ||
|
|
587bdc75de | ||
|
|
98f54c9f7f | ||
|
|
cd1d10761f | ||
|
|
9de9bc23f8 | ||
|
|
02f68ebac8 | ||
|
|
dd3f24b83f | ||
|
|
bc63c577a9 | ||
|
|
57c81e4153 | ||
|
|
556ca5fec7 | ||
|
|
93682ab708 | ||
|
|
6eeee8e5c7 | ||
|
|
d195847d8f | ||
|
|
3d8dc9d2bf | ||
|
|
8601dd1f42 | ||
|
|
3abdc870d8 | ||
|
|
367f8489db | ||
|
|
c312f8bf4a | ||
|
|
1f43c39f93 | ||
|
|
9f03a012fb | ||
|
|
22dd61d849 | ||
|
|
a92f6abc6e | ||
|
|
9cdaa9730b | ||
|
|
5d67ed0ce1 | ||
|
|
62d774b6ee | ||
|
|
a14f50eeca | ||
|
|
98e98a8adb | ||
|
|
fa7ef3df2f | ||
|
|
c3324371d6 | ||
|
|
6e08241712 | ||
|
|
c07dd3f14f | ||
|
|
b2ae9b6cac | ||
|
|
57536b020e | ||
|
|
0003e30084 | ||
|
|
23be13b113 | ||
|
|
5e44266292 | ||
|
|
32522cb482 | ||
|
|
6d296a195d | ||
|
|
3272febfb3 | ||
|
|
7dae780be1 | ||
|
|
73f1c06f65 | ||
|
|
b60727b205 | ||
|
|
8cee31d8d7 | ||
|
|
b5aace6d3a | ||
|
|
7e286c570e | ||
|
|
52fd13bfc4 | ||
|
|
b8e4aeede8 | ||
|
|
9a632c17d1 | ||
|
|
8758ee1c4d | ||
|
|
150ae1846a | ||
|
|
452286552c | ||
|
|
631cf58ff0 | ||
|
|
8a2c0e88f4 | ||
|
|
af6a47fdd3 | ||
|
|
94d910557f | ||
|
|
a8a683d3cc | ||
|
|
a1caa5b45c | ||
|
|
f42868f67f | ||
|
|
a6455653c0 | ||
|
|
c8aa653275 | ||
|
|
91e5cbd793 | ||
|
|
79fc74c7a4 | ||
|
|
c8503075e0 | ||
|
|
4068a7b00b | ||
|
|
daae2fe549 | ||
|
|
47bbb85a20 | ||
|
|
739653fa71 | ||
|
|
304109a6c5 | ||
|
|
c29af96a19 | ||
|
|
d21e9d29d1 | ||
|
|
b65bd5baa8 | ||
|
|
0165b89941 | ||
|
|
53b62f3f39 | ||
|
|
cd2914ab3b | ||
|
|
e85b97143c | ||
|
|
1eafe960b8 | ||
|
|
749c92954c | ||
|
|
db9ba17920 | ||
|
|
d5ce7d7523 | ||
|
|
2e6687209b | ||
|
|
2e04abf4bb | ||
|
|
882c0c34c1 | ||
|
|
61ebb713f2 | ||
|
|
ac5ad42474 | ||
|
|
d68d7d5a6f | ||
|
|
bff9036f14 | ||
|
|
8b08c2a918 | ||
|
|
b9f0fabb5c | ||
|
|
9d4822b8c7 | ||
|
|
466d03d574 | ||
|
|
d43fec7f96 | ||
|
|
62f4c205f5 | ||
|
|
003c19004d | ||
|
|
70274d528c | ||
|
|
6d41279781 | ||
|
|
b781446e86 | ||
|
|
1c9b1c0579 | ||
|
|
ade9552736 | ||
|
|
68403cb76e | ||
|
|
537ecb8db0 | ||
|
|
8f5875efe4 | ||
|
|
98ac88d5ef | ||
|
|
d13338a9fb | ||
|
|
1579ffb66a | ||
|
|
0bfa5302a7 | ||
|
|
b8aad5451d | ||
|
|
60ee04674d | ||
|
|
9901d6b2e7 | ||
|
|
663e8384a3 | ||
|
|
61440c42d3 | ||
|
|
18ee6274e1 | ||
|
|
0abfbdc18a | ||
|
|
082a852c5e | ||
|
|
af081e9fd3 | ||
|
|
8b5e8b7dfc | ||
|
|
1e7d7e510e | ||
|
|
a806694d23 | ||
|
|
62d7fae056 | ||
|
|
06d85688fd | ||
|
|
dd219d0ff6 | ||
|
|
6087e1cf6f | ||
|
|
c47fb1ae54 | ||
|
|
48cec3cd90 | ||
|
|
e54c508c10 | ||
|
|
941e9d9b0f | ||
|
|
11ccae8e52 | ||
|
|
b803240dc1 | ||
|
|
bdbf620ece | ||
|
|
e5d22b8a70 | ||
|
|
05c5e2280b | ||
|
|
b41d89946a | ||
|
|
cc0c88a63a | ||
|
|
c06689dec1 | ||
|
|
b85dd7abbd | ||
|
|
6aeaff43aa | ||
|
|
dd26cbd193 | ||
|
|
9a60eeaf86 | ||
|
|
b0ae3240fd | ||
|
|
41efe98953 | ||
|
|
2b68c90778 | ||
|
|
f19c048569 | ||
|
|
6cc8bbc24f | ||
|
|
c24de595f6 | ||
|
|
63641a7b17 | ||
|
|
a6570d33a6 | ||
|
|
124d8a3424 | ||
|
|
5de9de14a9 | ||
|
|
15f8cb5034 | ||
|
|
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 | ||
|
|
8f5a1dce3e | ||
|
|
6b0f5da113 | ||
|
|
d1ebcb59f1 | ||
|
|
31344128a0 | ||
|
|
86ecc2a234 | ||
|
|
d1e8ac7ba5 | ||
|
|
efe208fef5 | ||
|
|
7b40e99aec | ||
|
|
06706aab9a | ||
|
|
0318af5a33 | ||
|
|
995dcfc6ae | ||
|
|
2236cc8bf7 | ||
|
|
7bb354117b | ||
|
|
dbe193ad17 | ||
|
|
e7424222db | ||
|
|
da14750396 | ||
|
|
8fe72dcb74 | ||
|
|
677bd9b657 | ||
|
|
a347d276bd | ||
|
|
710616f118 | ||
|
|
d0cd5af419 | ||
|
|
afbfc1d370 | ||
|
|
0603e29c46 | ||
|
|
8843188b84 | ||
|
|
74e6c1479e | ||
|
|
2997f4d251 | ||
|
|
e407d423d4 | ||
|
|
35795c79c3 | ||
|
|
c487591437 | ||
|
|
0393ab524c | ||
|
|
cc054d71fe | ||
|
|
8248b71153 | ||
|
|
b22a9781a2 | ||
|
|
e7a2501fe8 |
@@ -3,6 +3,7 @@
|
|||||||
// development
|
// development
|
||||||
integration_test.go
|
integration_test.go
|
||||||
integration_test/
|
integration_test/
|
||||||
|
!integration_test/etc_embedded_derp/tls/server.crt
|
||||||
|
|
||||||
Dockerfile*
|
Dockerfile*
|
||||||
docker-compose*
|
docker-compose*
|
||||||
@@ -15,3 +16,4 @@ README.md
|
|||||||
LICENSE
|
LICENSE
|
||||||
.vscode
|
.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
|
||||||
3
.github/FUNDING.yml
vendored
Normal file
3
.github/FUNDING.yml
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# These are supported funding model platforms
|
||||||
|
|
||||||
|
ko_fi: headscale
|
||||||
30
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
30
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
---
|
||||||
|
name: "Bug report"
|
||||||
|
about: "Create a bug report to help us improve"
|
||||||
|
title: ""
|
||||||
|
labels: ["bug"]
|
||||||
|
assignees: ""
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- Headscale is a multinational community across the globe. Our common language is English. Please consider raising the bug report in this language. -->
|
||||||
|
|
||||||
|
**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.gg/xGj2TuqyxY"
|
||||||
|
about: "Please ask and answer questions about usage of headscale here."
|
||||||
17
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
17
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
---
|
||||||
|
name: "Feature request"
|
||||||
|
about: "Suggest an idea for headscale"
|
||||||
|
title: ""
|
||||||
|
labels: ["enhancement"]
|
||||||
|
assignees: ""
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- Headscale is a multinational community across the globe. Our common language is English. Please consider raising the feature request in this language. -->
|
||||||
|
|
||||||
|
**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? -->
|
||||||
30
.github/ISSUE_TEMPLATE/other_issue.md
vendored
Normal file
30
.github/ISSUE_TEMPLATE/other_issue.md
vendored
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
---
|
||||||
|
name: "Other issue"
|
||||||
|
about: "Report a different issue"
|
||||||
|
title: ""
|
||||||
|
labels: ["bug"]
|
||||||
|
assignees: ""
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- Headscale is a multinational community across the globe. Our common language is English. Please consider raising the issue in this language. -->
|
||||||
|
|
||||||
|
<!-- 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#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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
63
.github/workflows/build.yml
vendored
63
.github/workflows/build.yml
vendored
@@ -8,29 +8,64 @@ on:
|
|||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-$${{ github.head_ref || github.run_id }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
permissions: write-all
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v2
|
- uses: actions/checkout@v3
|
||||||
|
|
||||||
- name: Setup Go
|
|
||||||
uses: actions/setup-go@v2
|
|
||||||
with:
|
with:
|
||||||
go-version: "1.16.3"
|
fetch-depth: 2
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Get changed files
|
||||||
|
id: changed-files
|
||||||
|
uses: tj-actions/changed-files@v34
|
||||||
|
with:
|
||||||
|
files: |
|
||||||
|
*.nix
|
||||||
|
go.*
|
||||||
|
**/*.go
|
||||||
|
integration_test/
|
||||||
|
config-example.yaml
|
||||||
|
|
||||||
|
- uses: cachix/install-nix-action@v16
|
||||||
|
if: steps.changed-files.outputs.any_changed == 'true'
|
||||||
|
|
||||||
|
- name: Run build
|
||||||
|
id: build
|
||||||
|
if: steps.changed-files.outputs.any_changed == 'true'
|
||||||
run: |
|
run: |
|
||||||
go version
|
nix build |& tee build-result
|
||||||
go install golang.org/x/lint/golint@latest
|
BUILD_STATUS="${PIPESTATUS[0]}"
|
||||||
sudo apt update
|
|
||||||
sudo apt install -y make
|
|
||||||
|
|
||||||
- name: Run lint
|
OLD_HASH=$(cat build-result | grep specified: | awk -F ':' '{print $2}' | sed 's/ //g')
|
||||||
run: make build
|
NEW_HASH=$(cat build-result | grep got: | awk -F ':' '{print $2}' | sed 's/ //g')
|
||||||
|
|
||||||
- uses: actions/upload-artifact@v2
|
echo "OLD_HASH=$OLD_HASH" >> $GITHUB_OUTPUT
|
||||||
|
echo "NEW_HASH=$NEW_HASH" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
exit $BUILD_STATUS
|
||||||
|
|
||||||
|
- name: Nix gosum diverging
|
||||||
|
uses: actions/github-script@v6
|
||||||
|
if: failure() && steps.build.outcome == 'failure'
|
||||||
|
with:
|
||||||
|
github-token: ${{secrets.GITHUB_TOKEN}}
|
||||||
|
script: |
|
||||||
|
github.rest.pulls.createReviewComment({
|
||||||
|
pull_number: context.issue.number,
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
body: 'Nix build failed with wrong gosum, please update "vendorSha256" (${{ steps.build.outputs.OLD_HASH }}) for the "headscale" package in flake.nix with the new SHA: ${{ steps.build.outputs.NEW_HASH }}'
|
||||||
|
})
|
||||||
|
|
||||||
|
- uses: actions/upload-artifact@v3
|
||||||
|
if: steps.changed-files.outputs.any_changed == 'true'
|
||||||
with:
|
with:
|
||||||
name: headscale-linux
|
name: headscale-linux
|
||||||
path: headscale
|
path: result/bin/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@v3
|
||||||
|
- 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"
|
||||||
23
.github/workflows/gh-actions-updater.yaml
vendored
Normal file
23
.github/workflows/gh-actions-updater.yaml
vendored
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
name: GitHub Actions Version Updater
|
||||||
|
|
||||||
|
# Controls when the action will run.
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
# Automatically run on every Sunday
|
||||||
|
- cron: "0 0 * * 0"
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v2
|
||||||
|
with:
|
||||||
|
# [Required] Access token with `workflow` scope.
|
||||||
|
token: ${{ secrets.WORKFLOW_SECRET }}
|
||||||
|
|
||||||
|
- name: Run GitHub Actions Version Updater
|
||||||
|
uses: saadmk11/github-actions-version-updater@v0.7.1
|
||||||
|
with:
|
||||||
|
# [Required] Access token with `workflow` scope.
|
||||||
|
token: ${{ secrets.WORKFLOW_SECRET }}
|
||||||
95
.github/workflows/lint.yml
vendored
95
.github/workflows/lint.yml
vendored
@@ -1,39 +1,80 @@
|
|||||||
name: CI
|
---
|
||||||
|
name: Lint
|
||||||
|
|
||||||
on: [push, pull_request]
|
on: [push, pull_request]
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-$${{ github.head_ref || github.run_id }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
# The "build" workflow
|
golangci-lint:
|
||||||
lint:
|
runs-on: ubuntu-latest
|
||||||
# The type of runner that the job will run on
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
with:
|
||||||
|
fetch-depth: 2
|
||||||
|
|
||||||
|
- name: Get changed files
|
||||||
|
id: changed-files
|
||||||
|
uses: tj-actions/changed-files@v34
|
||||||
|
with:
|
||||||
|
files: |
|
||||||
|
*.nix
|
||||||
|
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: v1.51.2
|
||||||
|
|
||||||
|
# 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
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
# Steps represent a sequence of tasks that will be executed as part of the job
|
|
||||||
steps:
|
steps:
|
||||||
# Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it
|
|
||||||
- uses: actions/checkout@v2
|
- uses: actions/checkout@v2
|
||||||
|
|
||||||
# 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:
|
with:
|
||||||
args: --timeout 5m
|
fetch-depth: 2
|
||||||
|
|
||||||
# Setup Go
|
- name: Get changed files
|
||||||
- name: Setup Go
|
id: changed-files
|
||||||
uses: actions/setup-go@v2
|
uses: tj-actions/changed-files@v14.1
|
||||||
with:
|
with:
|
||||||
go-version: "1.16.3" # The Go version to download (if necessary) and use.
|
files: |
|
||||||
|
*.nix
|
||||||
|
**/*.md
|
||||||
|
**/*.yml
|
||||||
|
**/*.yaml
|
||||||
|
**/*.ts
|
||||||
|
**/*.js
|
||||||
|
**/*.sass
|
||||||
|
**/*.css
|
||||||
|
**/*.scss
|
||||||
|
**/*.html
|
||||||
|
|
||||||
# Install all the dependencies
|
- name: Prettify code
|
||||||
- name: Install dependencies
|
if: steps.changed-files.outputs.any_changed == 'true'
|
||||||
run: |
|
uses: creyD/prettier_action@v4.3
|
||||||
go version
|
with:
|
||||||
go install golang.org/x/lint/golint@latest
|
prettier_options: >-
|
||||||
sudo apt update
|
--check **/*.{ts,js,md,yaml,yml,sass,css,scss,html}
|
||||||
sudo apt install -y make
|
only_changed: false
|
||||||
|
dry: true
|
||||||
|
|
||||||
- name: Run lint
|
proto-lint:
|
||||||
run: make lint
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v2
|
||||||
|
- uses: bufbuild/buf-setup-action@v1.7.0
|
||||||
|
- uses: bufbuild/buf-lint-action@v1
|
||||||
|
with:
|
||||||
|
input: "proto"
|
||||||
|
|||||||
138
.github/workflows/release-docker.yml
vendored
Normal file
138
.github/workflows/release-docker.yml
vendored
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
---
|
||||||
|
name: Release Docker
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- "*" # triggers only if push new tag version
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
docker-release:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v3
|
||||||
|
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=sha
|
||||||
|
type=raw,value=develop
|
||||||
|
- 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
|
||||||
|
build-args: |
|
||||||
|
VERSION=${{ steps.meta.outputs.version }}
|
||||||
|
- 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@v3
|
||||||
|
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: |
|
||||||
|
suffix=-debug,onlatest=true
|
||||||
|
tags: |
|
||||||
|
type=semver,pattern={{version}}
|
||||||
|
type=semver,pattern={{major}}.{{minor}}
|
||||||
|
type=semver,pattern={{major}}
|
||||||
|
type=sha
|
||||||
|
type=raw,value=develop
|
||||||
|
- 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
|
||||||
|
build-args: |
|
||||||
|
VERSION=${{ steps.meta-debug.outputs.version }}
|
||||||
|
- name: Prepare cache for next build
|
||||||
|
run: |
|
||||||
|
rm -rf /tmp/.buildx-cache-debug
|
||||||
|
mv /tmp/.buildx-cache-debug-new /tmp/.buildx-cache-debug
|
||||||
75
.github/workflows/release.yml
vendored
75
.github/workflows/release.yml
vendored
@@ -1,77 +1,24 @@
|
|||||||
---
|
---
|
||||||
name: release
|
name: Release
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
tags:
|
tags:
|
||||||
- "*" # triggers only if push new tag version
|
- "*" # triggers only if push new tag version
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
goreleaser:
|
goreleaser:
|
||||||
runs-on: ubuntu-18.04 # due to CGO we need to user an older version
|
|
||||||
steps:
|
|
||||||
-
|
|
||||||
name: Checkout
|
|
||||||
uses: actions/checkout@v2
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
-
|
|
||||||
name: Set up Go
|
|
||||||
uses: actions/setup-go@v2
|
|
||||||
with:
|
|
||||||
go-version: 1.16
|
|
||||||
-
|
|
||||||
name: Run GoReleaser
|
|
||||||
uses: goreleaser/goreleaser-action@v2
|
|
||||||
with:
|
|
||||||
distribution: goreleaser
|
|
||||||
version: latest
|
|
||||||
args: release --rm-dist
|
|
||||||
env:
|
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
|
|
||||||
docker-release:
|
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
-
|
- name: Checkout
|
||||||
name: Checkout
|
uses: actions/checkout@v3
|
||||||
uses: actions/checkout@v2
|
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
-
|
|
||||||
name: Docker meta
|
- uses: cachix/install-nix-action@v16
|
||||||
id: meta
|
|
||||||
uses: docker/metadata-action@v3
|
- name: Run goreleaser
|
||||||
with:
|
run: nix develop --command -- goreleaser release --rm-dist
|
||||||
# list of Docker images to use as base name for tags
|
env:
|
||||||
images: |
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
${{ 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=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 }}
|
|
||||||
|
|||||||
35
.github/workflows/test-integration-cli.yml
vendored
Normal file
35
.github/workflows/test-integration-cli.yml
vendored
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
name: Integration Test CLI
|
||||||
|
|
||||||
|
on: [pull_request]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
integration-test-cli:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
with:
|
||||||
|
fetch-depth: 2
|
||||||
|
|
||||||
|
- name: Set Swap Space
|
||||||
|
uses: pierotofy/set-swap-space@master
|
||||||
|
with:
|
||||||
|
swap-size-gb: 10
|
||||||
|
|
||||||
|
- name: Get changed files
|
||||||
|
id: changed-files
|
||||||
|
uses: tj-actions/changed-files@v34
|
||||||
|
with:
|
||||||
|
files: |
|
||||||
|
*.nix
|
||||||
|
go.*
|
||||||
|
**/*.go
|
||||||
|
integration_test/
|
||||||
|
config-example.yaml
|
||||||
|
|
||||||
|
- uses: cachix/install-nix-action@v16
|
||||||
|
if: steps.changed-files.outputs.any_changed == 'true'
|
||||||
|
|
||||||
|
- name: Run CLI integration tests
|
||||||
|
if: steps.changed-files.outputs.any_changed == 'true'
|
||||||
|
run: nix develop --command -- make test_integration_cli
|
||||||
35
.github/workflows/test-integration-derp.yml
vendored
Normal file
35
.github/workflows/test-integration-derp.yml
vendored
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
name: Integration Test DERP
|
||||||
|
|
||||||
|
on: [pull_request]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
integration-test-derp:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
with:
|
||||||
|
fetch-depth: 2
|
||||||
|
|
||||||
|
- name: Set Swap Space
|
||||||
|
uses: pierotofy/set-swap-space@master
|
||||||
|
with:
|
||||||
|
swap-size-gb: 10
|
||||||
|
|
||||||
|
- name: Get changed files
|
||||||
|
id: changed-files
|
||||||
|
uses: tj-actions/changed-files@v34
|
||||||
|
with:
|
||||||
|
files: |
|
||||||
|
*.nix
|
||||||
|
go.*
|
||||||
|
**/*.go
|
||||||
|
integration_test/
|
||||||
|
config-example.yaml
|
||||||
|
|
||||||
|
- uses: cachix/install-nix-action@v16
|
||||||
|
if: steps.changed-files.outputs.any_changed == 'true'
|
||||||
|
|
||||||
|
- name: Run Embedded DERP server integration tests
|
||||||
|
if: steps.changed-files.outputs.any_changed == 'true'
|
||||||
|
run: nix develop --command -- make test_integration_derp
|
||||||
57
.github/workflows/test-integration-v2-TestACLAllowStarDst.yaml
vendored
Normal file
57
.github/workflows/test-integration-v2-TestACLAllowStarDst.yaml
vendored
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
# DO NOT EDIT, generated with cmd/gh-action-integration-generator/main.go
|
||||||
|
# To regenerate, run "go generate" in cmd/gh-action-integration-generator/
|
||||||
|
|
||||||
|
name: Integration Test v2 - TestACLAllowStarDst
|
||||||
|
|
||||||
|
on: [pull_request]
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-$${{ github.head_ref || github.run_id }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
with:
|
||||||
|
fetch-depth: 2
|
||||||
|
|
||||||
|
- name: Get changed files
|
||||||
|
id: changed-files
|
||||||
|
uses: tj-actions/changed-files@v34
|
||||||
|
with:
|
||||||
|
files: |
|
||||||
|
*.nix
|
||||||
|
go.*
|
||||||
|
**/*.go
|
||||||
|
integration_test/
|
||||||
|
config-example.yaml
|
||||||
|
|
||||||
|
- uses: cachix/install-nix-action@v18
|
||||||
|
if: ${{ env.ACT }} || steps.changed-files.outputs.any_changed == 'true'
|
||||||
|
|
||||||
|
- name: Run general integration tests
|
||||||
|
if: steps.changed-files.outputs.any_changed == 'true'
|
||||||
|
run: |
|
||||||
|
nix develop --command -- docker run \
|
||||||
|
--tty --rm \
|
||||||
|
--volume ~/.cache/hs-integration-go:/go \
|
||||||
|
--name headscale-test-suite \
|
||||||
|
--volume $PWD:$PWD -w $PWD/integration \
|
||||||
|
--volume /var/run/docker.sock:/var/run/docker.sock \
|
||||||
|
--volume $PWD/control_logs:/tmp/control \
|
||||||
|
golang:1 \
|
||||||
|
go test ./... \
|
||||||
|
-tags ts2019 \
|
||||||
|
-failfast \
|
||||||
|
-timeout 120m \
|
||||||
|
-parallel 1 \
|
||||||
|
-run "^TestACLAllowStarDst$"
|
||||||
|
|
||||||
|
- uses: actions/upload-artifact@v3
|
||||||
|
if: always() && steps.changed-files.outputs.any_changed == 'true'
|
||||||
|
with:
|
||||||
|
name: logs
|
||||||
|
path: "control_logs/*.log"
|
||||||
57
.github/workflows/test-integration-v2-TestACLAllowUser80Dst.yaml
vendored
Normal file
57
.github/workflows/test-integration-v2-TestACLAllowUser80Dst.yaml
vendored
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
# DO NOT EDIT, generated with cmd/gh-action-integration-generator/main.go
|
||||||
|
# To regenerate, run "go generate" in cmd/gh-action-integration-generator/
|
||||||
|
|
||||||
|
name: Integration Test v2 - TestACLAllowUser80Dst
|
||||||
|
|
||||||
|
on: [pull_request]
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-$${{ github.head_ref || github.run_id }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
with:
|
||||||
|
fetch-depth: 2
|
||||||
|
|
||||||
|
- name: Get changed files
|
||||||
|
id: changed-files
|
||||||
|
uses: tj-actions/changed-files@v34
|
||||||
|
with:
|
||||||
|
files: |
|
||||||
|
*.nix
|
||||||
|
go.*
|
||||||
|
**/*.go
|
||||||
|
integration_test/
|
||||||
|
config-example.yaml
|
||||||
|
|
||||||
|
- uses: cachix/install-nix-action@v18
|
||||||
|
if: ${{ env.ACT }} || steps.changed-files.outputs.any_changed == 'true'
|
||||||
|
|
||||||
|
- name: Run general integration tests
|
||||||
|
if: steps.changed-files.outputs.any_changed == 'true'
|
||||||
|
run: |
|
||||||
|
nix develop --command -- docker run \
|
||||||
|
--tty --rm \
|
||||||
|
--volume ~/.cache/hs-integration-go:/go \
|
||||||
|
--name headscale-test-suite \
|
||||||
|
--volume $PWD:$PWD -w $PWD/integration \
|
||||||
|
--volume /var/run/docker.sock:/var/run/docker.sock \
|
||||||
|
--volume $PWD/control_logs:/tmp/control \
|
||||||
|
golang:1 \
|
||||||
|
go test ./... \
|
||||||
|
-tags ts2019 \
|
||||||
|
-failfast \
|
||||||
|
-timeout 120m \
|
||||||
|
-parallel 1 \
|
||||||
|
-run "^TestACLAllowUser80Dst$"
|
||||||
|
|
||||||
|
- uses: actions/upload-artifact@v3
|
||||||
|
if: always() && steps.changed-files.outputs.any_changed == 'true'
|
||||||
|
with:
|
||||||
|
name: logs
|
||||||
|
path: "control_logs/*.log"
|
||||||
57
.github/workflows/test-integration-v2-TestACLAllowUserDst.yaml
vendored
Normal file
57
.github/workflows/test-integration-v2-TestACLAllowUserDst.yaml
vendored
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
# DO NOT EDIT, generated with cmd/gh-action-integration-generator/main.go
|
||||||
|
# To regenerate, run "go generate" in cmd/gh-action-integration-generator/
|
||||||
|
|
||||||
|
name: Integration Test v2 - TestACLAllowUserDst
|
||||||
|
|
||||||
|
on: [pull_request]
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-$${{ github.head_ref || github.run_id }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
with:
|
||||||
|
fetch-depth: 2
|
||||||
|
|
||||||
|
- name: Get changed files
|
||||||
|
id: changed-files
|
||||||
|
uses: tj-actions/changed-files@v34
|
||||||
|
with:
|
||||||
|
files: |
|
||||||
|
*.nix
|
||||||
|
go.*
|
||||||
|
**/*.go
|
||||||
|
integration_test/
|
||||||
|
config-example.yaml
|
||||||
|
|
||||||
|
- uses: cachix/install-nix-action@v18
|
||||||
|
if: ${{ env.ACT }} || steps.changed-files.outputs.any_changed == 'true'
|
||||||
|
|
||||||
|
- name: Run general integration tests
|
||||||
|
if: steps.changed-files.outputs.any_changed == 'true'
|
||||||
|
run: |
|
||||||
|
nix develop --command -- docker run \
|
||||||
|
--tty --rm \
|
||||||
|
--volume ~/.cache/hs-integration-go:/go \
|
||||||
|
--name headscale-test-suite \
|
||||||
|
--volume $PWD:$PWD -w $PWD/integration \
|
||||||
|
--volume /var/run/docker.sock:/var/run/docker.sock \
|
||||||
|
--volume $PWD/control_logs:/tmp/control \
|
||||||
|
golang:1 \
|
||||||
|
go test ./... \
|
||||||
|
-tags ts2019 \
|
||||||
|
-failfast \
|
||||||
|
-timeout 120m \
|
||||||
|
-parallel 1 \
|
||||||
|
-run "^TestACLAllowUserDst$"
|
||||||
|
|
||||||
|
- uses: actions/upload-artifact@v3
|
||||||
|
if: always() && steps.changed-files.outputs.any_changed == 'true'
|
||||||
|
with:
|
||||||
|
name: logs
|
||||||
|
path: "control_logs/*.log"
|
||||||
57
.github/workflows/test-integration-v2-TestACLDenyAllPort80.yaml
vendored
Normal file
57
.github/workflows/test-integration-v2-TestACLDenyAllPort80.yaml
vendored
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
# DO NOT EDIT, generated with cmd/gh-action-integration-generator/main.go
|
||||||
|
# To regenerate, run "go generate" in cmd/gh-action-integration-generator/
|
||||||
|
|
||||||
|
name: Integration Test v2 - TestACLDenyAllPort80
|
||||||
|
|
||||||
|
on: [pull_request]
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-$${{ github.head_ref || github.run_id }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
with:
|
||||||
|
fetch-depth: 2
|
||||||
|
|
||||||
|
- name: Get changed files
|
||||||
|
id: changed-files
|
||||||
|
uses: tj-actions/changed-files@v34
|
||||||
|
with:
|
||||||
|
files: |
|
||||||
|
*.nix
|
||||||
|
go.*
|
||||||
|
**/*.go
|
||||||
|
integration_test/
|
||||||
|
config-example.yaml
|
||||||
|
|
||||||
|
- uses: cachix/install-nix-action@v18
|
||||||
|
if: ${{ env.ACT }} || steps.changed-files.outputs.any_changed == 'true'
|
||||||
|
|
||||||
|
- name: Run general integration tests
|
||||||
|
if: steps.changed-files.outputs.any_changed == 'true'
|
||||||
|
run: |
|
||||||
|
nix develop --command -- docker run \
|
||||||
|
--tty --rm \
|
||||||
|
--volume ~/.cache/hs-integration-go:/go \
|
||||||
|
--name headscale-test-suite \
|
||||||
|
--volume $PWD:$PWD -w $PWD/integration \
|
||||||
|
--volume /var/run/docker.sock:/var/run/docker.sock \
|
||||||
|
--volume $PWD/control_logs:/tmp/control \
|
||||||
|
golang:1 \
|
||||||
|
go test ./... \
|
||||||
|
-tags ts2019 \
|
||||||
|
-failfast \
|
||||||
|
-timeout 120m \
|
||||||
|
-parallel 1 \
|
||||||
|
-run "^TestACLDenyAllPort80$"
|
||||||
|
|
||||||
|
- uses: actions/upload-artifact@v3
|
||||||
|
if: always() && steps.changed-files.outputs.any_changed == 'true'
|
||||||
|
with:
|
||||||
|
name: logs
|
||||||
|
path: "control_logs/*.log"
|
||||||
57
.github/workflows/test-integration-v2-TestACLHostsInNetMapTable.yaml
vendored
Normal file
57
.github/workflows/test-integration-v2-TestACLHostsInNetMapTable.yaml
vendored
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
# DO NOT EDIT, generated with cmd/gh-action-integration-generator/main.go
|
||||||
|
# To regenerate, run "go generate" in cmd/gh-action-integration-generator/
|
||||||
|
|
||||||
|
name: Integration Test v2 - TestACLHostsInNetMapTable
|
||||||
|
|
||||||
|
on: [pull_request]
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-$${{ github.head_ref || github.run_id }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
with:
|
||||||
|
fetch-depth: 2
|
||||||
|
|
||||||
|
- name: Get changed files
|
||||||
|
id: changed-files
|
||||||
|
uses: tj-actions/changed-files@v34
|
||||||
|
with:
|
||||||
|
files: |
|
||||||
|
*.nix
|
||||||
|
go.*
|
||||||
|
**/*.go
|
||||||
|
integration_test/
|
||||||
|
config-example.yaml
|
||||||
|
|
||||||
|
- uses: cachix/install-nix-action@v18
|
||||||
|
if: ${{ env.ACT }} || steps.changed-files.outputs.any_changed == 'true'
|
||||||
|
|
||||||
|
- name: Run general integration tests
|
||||||
|
if: steps.changed-files.outputs.any_changed == 'true'
|
||||||
|
run: |
|
||||||
|
nix develop --command -- docker run \
|
||||||
|
--tty --rm \
|
||||||
|
--volume ~/.cache/hs-integration-go:/go \
|
||||||
|
--name headscale-test-suite \
|
||||||
|
--volume $PWD:$PWD -w $PWD/integration \
|
||||||
|
--volume /var/run/docker.sock:/var/run/docker.sock \
|
||||||
|
--volume $PWD/control_logs:/tmp/control \
|
||||||
|
golang:1 \
|
||||||
|
go test ./... \
|
||||||
|
-tags ts2019 \
|
||||||
|
-failfast \
|
||||||
|
-timeout 120m \
|
||||||
|
-parallel 1 \
|
||||||
|
-run "^TestACLHostsInNetMapTable$"
|
||||||
|
|
||||||
|
- uses: actions/upload-artifact@v3
|
||||||
|
if: always() && steps.changed-files.outputs.any_changed == 'true'
|
||||||
|
with:
|
||||||
|
name: logs
|
||||||
|
path: "control_logs/*.log"
|
||||||
57
.github/workflows/test-integration-v2-TestAuthKeyLogoutAndRelogin.yaml
vendored
Normal file
57
.github/workflows/test-integration-v2-TestAuthKeyLogoutAndRelogin.yaml
vendored
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
# DO NOT EDIT, generated with cmd/gh-action-integration-generator/main.go
|
||||||
|
# To regenerate, run "go generate" in cmd/gh-action-integration-generator/
|
||||||
|
|
||||||
|
name: Integration Test v2 - TestAuthKeyLogoutAndRelogin
|
||||||
|
|
||||||
|
on: [pull_request]
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-$${{ github.head_ref || github.run_id }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
with:
|
||||||
|
fetch-depth: 2
|
||||||
|
|
||||||
|
- name: Get changed files
|
||||||
|
id: changed-files
|
||||||
|
uses: tj-actions/changed-files@v34
|
||||||
|
with:
|
||||||
|
files: |
|
||||||
|
*.nix
|
||||||
|
go.*
|
||||||
|
**/*.go
|
||||||
|
integration_test/
|
||||||
|
config-example.yaml
|
||||||
|
|
||||||
|
- uses: cachix/install-nix-action@v18
|
||||||
|
if: ${{ env.ACT }} || steps.changed-files.outputs.any_changed == 'true'
|
||||||
|
|
||||||
|
- name: Run general integration tests
|
||||||
|
if: steps.changed-files.outputs.any_changed == 'true'
|
||||||
|
run: |
|
||||||
|
nix develop --command -- docker run \
|
||||||
|
--tty --rm \
|
||||||
|
--volume ~/.cache/hs-integration-go:/go \
|
||||||
|
--name headscale-test-suite \
|
||||||
|
--volume $PWD:$PWD -w $PWD/integration \
|
||||||
|
--volume /var/run/docker.sock:/var/run/docker.sock \
|
||||||
|
--volume $PWD/control_logs:/tmp/control \
|
||||||
|
golang:1 \
|
||||||
|
go test ./... \
|
||||||
|
-tags ts2019 \
|
||||||
|
-failfast \
|
||||||
|
-timeout 120m \
|
||||||
|
-parallel 1 \
|
||||||
|
-run "^TestAuthKeyLogoutAndRelogin$"
|
||||||
|
|
||||||
|
- uses: actions/upload-artifact@v3
|
||||||
|
if: always() && steps.changed-files.outputs.any_changed == 'true'
|
||||||
|
with:
|
||||||
|
name: logs
|
||||||
|
path: "control_logs/*.log"
|
||||||
57
.github/workflows/test-integration-v2-TestAuthWebFlowAuthenticationPingAll.yaml
vendored
Normal file
57
.github/workflows/test-integration-v2-TestAuthWebFlowAuthenticationPingAll.yaml
vendored
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
# DO NOT EDIT, generated with cmd/gh-action-integration-generator/main.go
|
||||||
|
# To regenerate, run "go generate" in cmd/gh-action-integration-generator/
|
||||||
|
|
||||||
|
name: Integration Test v2 - TestAuthWebFlowAuthenticationPingAll
|
||||||
|
|
||||||
|
on: [pull_request]
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-$${{ github.head_ref || github.run_id }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
with:
|
||||||
|
fetch-depth: 2
|
||||||
|
|
||||||
|
- name: Get changed files
|
||||||
|
id: changed-files
|
||||||
|
uses: tj-actions/changed-files@v34
|
||||||
|
with:
|
||||||
|
files: |
|
||||||
|
*.nix
|
||||||
|
go.*
|
||||||
|
**/*.go
|
||||||
|
integration_test/
|
||||||
|
config-example.yaml
|
||||||
|
|
||||||
|
- uses: cachix/install-nix-action@v18
|
||||||
|
if: ${{ env.ACT }} || steps.changed-files.outputs.any_changed == 'true'
|
||||||
|
|
||||||
|
- name: Run general integration tests
|
||||||
|
if: steps.changed-files.outputs.any_changed == 'true'
|
||||||
|
run: |
|
||||||
|
nix develop --command -- docker run \
|
||||||
|
--tty --rm \
|
||||||
|
--volume ~/.cache/hs-integration-go:/go \
|
||||||
|
--name headscale-test-suite \
|
||||||
|
--volume $PWD:$PWD -w $PWD/integration \
|
||||||
|
--volume /var/run/docker.sock:/var/run/docker.sock \
|
||||||
|
--volume $PWD/control_logs:/tmp/control \
|
||||||
|
golang:1 \
|
||||||
|
go test ./... \
|
||||||
|
-tags ts2019 \
|
||||||
|
-failfast \
|
||||||
|
-timeout 120m \
|
||||||
|
-parallel 1 \
|
||||||
|
-run "^TestAuthWebFlowAuthenticationPingAll$"
|
||||||
|
|
||||||
|
- uses: actions/upload-artifact@v3
|
||||||
|
if: always() && steps.changed-files.outputs.any_changed == 'true'
|
||||||
|
with:
|
||||||
|
name: logs
|
||||||
|
path: "control_logs/*.log"
|
||||||
57
.github/workflows/test-integration-v2-TestAuthWebFlowLogoutAndRelogin.yaml
vendored
Normal file
57
.github/workflows/test-integration-v2-TestAuthWebFlowLogoutAndRelogin.yaml
vendored
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
# DO NOT EDIT, generated with cmd/gh-action-integration-generator/main.go
|
||||||
|
# To regenerate, run "go generate" in cmd/gh-action-integration-generator/
|
||||||
|
|
||||||
|
name: Integration Test v2 - TestAuthWebFlowLogoutAndRelogin
|
||||||
|
|
||||||
|
on: [pull_request]
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-$${{ github.head_ref || github.run_id }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
with:
|
||||||
|
fetch-depth: 2
|
||||||
|
|
||||||
|
- name: Get changed files
|
||||||
|
id: changed-files
|
||||||
|
uses: tj-actions/changed-files@v34
|
||||||
|
with:
|
||||||
|
files: |
|
||||||
|
*.nix
|
||||||
|
go.*
|
||||||
|
**/*.go
|
||||||
|
integration_test/
|
||||||
|
config-example.yaml
|
||||||
|
|
||||||
|
- uses: cachix/install-nix-action@v18
|
||||||
|
if: ${{ env.ACT }} || steps.changed-files.outputs.any_changed == 'true'
|
||||||
|
|
||||||
|
- name: Run general integration tests
|
||||||
|
if: steps.changed-files.outputs.any_changed == 'true'
|
||||||
|
run: |
|
||||||
|
nix develop --command -- docker run \
|
||||||
|
--tty --rm \
|
||||||
|
--volume ~/.cache/hs-integration-go:/go \
|
||||||
|
--name headscale-test-suite \
|
||||||
|
--volume $PWD:$PWD -w $PWD/integration \
|
||||||
|
--volume /var/run/docker.sock:/var/run/docker.sock \
|
||||||
|
--volume $PWD/control_logs:/tmp/control \
|
||||||
|
golang:1 \
|
||||||
|
go test ./... \
|
||||||
|
-tags ts2019 \
|
||||||
|
-failfast \
|
||||||
|
-timeout 120m \
|
||||||
|
-parallel 1 \
|
||||||
|
-run "^TestAuthWebFlowLogoutAndRelogin$"
|
||||||
|
|
||||||
|
- uses: actions/upload-artifact@v3
|
||||||
|
if: always() && steps.changed-files.outputs.any_changed == 'true'
|
||||||
|
with:
|
||||||
|
name: logs
|
||||||
|
path: "control_logs/*.log"
|
||||||
57
.github/workflows/test-integration-v2-TestCreateTailscale.yaml
vendored
Normal file
57
.github/workflows/test-integration-v2-TestCreateTailscale.yaml
vendored
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
# DO NOT EDIT, generated with cmd/gh-action-integration-generator/main.go
|
||||||
|
# To regenerate, run "go generate" in cmd/gh-action-integration-generator/
|
||||||
|
|
||||||
|
name: Integration Test v2 - TestCreateTailscale
|
||||||
|
|
||||||
|
on: [pull_request]
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-$${{ github.head_ref || github.run_id }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
with:
|
||||||
|
fetch-depth: 2
|
||||||
|
|
||||||
|
- name: Get changed files
|
||||||
|
id: changed-files
|
||||||
|
uses: tj-actions/changed-files@v34
|
||||||
|
with:
|
||||||
|
files: |
|
||||||
|
*.nix
|
||||||
|
go.*
|
||||||
|
**/*.go
|
||||||
|
integration_test/
|
||||||
|
config-example.yaml
|
||||||
|
|
||||||
|
- uses: cachix/install-nix-action@v18
|
||||||
|
if: ${{ env.ACT }} || steps.changed-files.outputs.any_changed == 'true'
|
||||||
|
|
||||||
|
- name: Run general integration tests
|
||||||
|
if: steps.changed-files.outputs.any_changed == 'true'
|
||||||
|
run: |
|
||||||
|
nix develop --command -- docker run \
|
||||||
|
--tty --rm \
|
||||||
|
--volume ~/.cache/hs-integration-go:/go \
|
||||||
|
--name headscale-test-suite \
|
||||||
|
--volume $PWD:$PWD -w $PWD/integration \
|
||||||
|
--volume /var/run/docker.sock:/var/run/docker.sock \
|
||||||
|
--volume $PWD/control_logs:/tmp/control \
|
||||||
|
golang:1 \
|
||||||
|
go test ./... \
|
||||||
|
-tags ts2019 \
|
||||||
|
-failfast \
|
||||||
|
-timeout 120m \
|
||||||
|
-parallel 1 \
|
||||||
|
-run "^TestCreateTailscale$"
|
||||||
|
|
||||||
|
- uses: actions/upload-artifact@v3
|
||||||
|
if: always() && steps.changed-files.outputs.any_changed == 'true'
|
||||||
|
with:
|
||||||
|
name: logs
|
||||||
|
path: "control_logs/*.log"
|
||||||
57
.github/workflows/test-integration-v2-TestEnablingRoutes.yaml
vendored
Normal file
57
.github/workflows/test-integration-v2-TestEnablingRoutes.yaml
vendored
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
# DO NOT EDIT, generated with cmd/gh-action-integration-generator/main.go
|
||||||
|
# To regenerate, run "go generate" in cmd/gh-action-integration-generator/
|
||||||
|
|
||||||
|
name: Integration Test v2 - TestEnablingRoutes
|
||||||
|
|
||||||
|
on: [pull_request]
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-$${{ github.head_ref || github.run_id }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
with:
|
||||||
|
fetch-depth: 2
|
||||||
|
|
||||||
|
- name: Get changed files
|
||||||
|
id: changed-files
|
||||||
|
uses: tj-actions/changed-files@v34
|
||||||
|
with:
|
||||||
|
files: |
|
||||||
|
*.nix
|
||||||
|
go.*
|
||||||
|
**/*.go
|
||||||
|
integration_test/
|
||||||
|
config-example.yaml
|
||||||
|
|
||||||
|
- uses: cachix/install-nix-action@v18
|
||||||
|
if: ${{ env.ACT }} || steps.changed-files.outputs.any_changed == 'true'
|
||||||
|
|
||||||
|
- name: Run general integration tests
|
||||||
|
if: steps.changed-files.outputs.any_changed == 'true'
|
||||||
|
run: |
|
||||||
|
nix develop --command -- docker run \
|
||||||
|
--tty --rm \
|
||||||
|
--volume ~/.cache/hs-integration-go:/go \
|
||||||
|
--name headscale-test-suite \
|
||||||
|
--volume $PWD:$PWD -w $PWD/integration \
|
||||||
|
--volume /var/run/docker.sock:/var/run/docker.sock \
|
||||||
|
--volume $PWD/control_logs:/tmp/control \
|
||||||
|
golang:1 \
|
||||||
|
go test ./... \
|
||||||
|
-tags ts2019 \
|
||||||
|
-failfast \
|
||||||
|
-timeout 120m \
|
||||||
|
-parallel 1 \
|
||||||
|
-run "^TestEnablingRoutes$"
|
||||||
|
|
||||||
|
- uses: actions/upload-artifact@v3
|
||||||
|
if: always() && steps.changed-files.outputs.any_changed == 'true'
|
||||||
|
with:
|
||||||
|
name: logs
|
||||||
|
path: "control_logs/*.log"
|
||||||
57
.github/workflows/test-integration-v2-TestEphemeral.yaml
vendored
Normal file
57
.github/workflows/test-integration-v2-TestEphemeral.yaml
vendored
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
# DO NOT EDIT, generated with cmd/gh-action-integration-generator/main.go
|
||||||
|
# To regenerate, run "go generate" in cmd/gh-action-integration-generator/
|
||||||
|
|
||||||
|
name: Integration Test v2 - TestEphemeral
|
||||||
|
|
||||||
|
on: [pull_request]
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-$${{ github.head_ref || github.run_id }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
with:
|
||||||
|
fetch-depth: 2
|
||||||
|
|
||||||
|
- name: Get changed files
|
||||||
|
id: changed-files
|
||||||
|
uses: tj-actions/changed-files@v34
|
||||||
|
with:
|
||||||
|
files: |
|
||||||
|
*.nix
|
||||||
|
go.*
|
||||||
|
**/*.go
|
||||||
|
integration_test/
|
||||||
|
config-example.yaml
|
||||||
|
|
||||||
|
- uses: cachix/install-nix-action@v18
|
||||||
|
if: ${{ env.ACT }} || steps.changed-files.outputs.any_changed == 'true'
|
||||||
|
|
||||||
|
- name: Run general integration tests
|
||||||
|
if: steps.changed-files.outputs.any_changed == 'true'
|
||||||
|
run: |
|
||||||
|
nix develop --command -- docker run \
|
||||||
|
--tty --rm \
|
||||||
|
--volume ~/.cache/hs-integration-go:/go \
|
||||||
|
--name headscale-test-suite \
|
||||||
|
--volume $PWD:$PWD -w $PWD/integration \
|
||||||
|
--volume /var/run/docker.sock:/var/run/docker.sock \
|
||||||
|
--volume $PWD/control_logs:/tmp/control \
|
||||||
|
golang:1 \
|
||||||
|
go test ./... \
|
||||||
|
-tags ts2019 \
|
||||||
|
-failfast \
|
||||||
|
-timeout 120m \
|
||||||
|
-parallel 1 \
|
||||||
|
-run "^TestEphemeral$"
|
||||||
|
|
||||||
|
- uses: actions/upload-artifact@v3
|
||||||
|
if: always() && steps.changed-files.outputs.any_changed == 'true'
|
||||||
|
with:
|
||||||
|
name: logs
|
||||||
|
path: "control_logs/*.log"
|
||||||
57
.github/workflows/test-integration-v2-TestExpireNode.yaml
vendored
Normal file
57
.github/workflows/test-integration-v2-TestExpireNode.yaml
vendored
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
# DO NOT EDIT, generated with cmd/gh-action-integration-generator/main.go
|
||||||
|
# To regenerate, run "go generate" in cmd/gh-action-integration-generator/
|
||||||
|
|
||||||
|
name: Integration Test v2 - TestExpireNode
|
||||||
|
|
||||||
|
on: [pull_request]
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-$${{ github.head_ref || github.run_id }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
with:
|
||||||
|
fetch-depth: 2
|
||||||
|
|
||||||
|
- name: Get changed files
|
||||||
|
id: changed-files
|
||||||
|
uses: tj-actions/changed-files@v34
|
||||||
|
with:
|
||||||
|
files: |
|
||||||
|
*.nix
|
||||||
|
go.*
|
||||||
|
**/*.go
|
||||||
|
integration_test/
|
||||||
|
config-example.yaml
|
||||||
|
|
||||||
|
- uses: cachix/install-nix-action@v18
|
||||||
|
if: ${{ env.ACT }} || steps.changed-files.outputs.any_changed == 'true'
|
||||||
|
|
||||||
|
- name: Run general integration tests
|
||||||
|
if: steps.changed-files.outputs.any_changed == 'true'
|
||||||
|
run: |
|
||||||
|
nix develop --command -- docker run \
|
||||||
|
--tty --rm \
|
||||||
|
--volume ~/.cache/hs-integration-go:/go \
|
||||||
|
--name headscale-test-suite \
|
||||||
|
--volume $PWD:$PWD -w $PWD/integration \
|
||||||
|
--volume /var/run/docker.sock:/var/run/docker.sock \
|
||||||
|
--volume $PWD/control_logs:/tmp/control \
|
||||||
|
golang:1 \
|
||||||
|
go test ./... \
|
||||||
|
-tags ts2019 \
|
||||||
|
-failfast \
|
||||||
|
-timeout 120m \
|
||||||
|
-parallel 1 \
|
||||||
|
-run "^TestExpireNode$"
|
||||||
|
|
||||||
|
- uses: actions/upload-artifact@v3
|
||||||
|
if: always() && steps.changed-files.outputs.any_changed == 'true'
|
||||||
|
with:
|
||||||
|
name: logs
|
||||||
|
path: "control_logs/*.log"
|
||||||
57
.github/workflows/test-integration-v2-TestHeadscale.yaml
vendored
Normal file
57
.github/workflows/test-integration-v2-TestHeadscale.yaml
vendored
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
# DO NOT EDIT, generated with cmd/gh-action-integration-generator/main.go
|
||||||
|
# To regenerate, run "go generate" in cmd/gh-action-integration-generator/
|
||||||
|
|
||||||
|
name: Integration Test v2 - TestHeadscale
|
||||||
|
|
||||||
|
on: [pull_request]
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-$${{ github.head_ref || github.run_id }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
with:
|
||||||
|
fetch-depth: 2
|
||||||
|
|
||||||
|
- name: Get changed files
|
||||||
|
id: changed-files
|
||||||
|
uses: tj-actions/changed-files@v34
|
||||||
|
with:
|
||||||
|
files: |
|
||||||
|
*.nix
|
||||||
|
go.*
|
||||||
|
**/*.go
|
||||||
|
integration_test/
|
||||||
|
config-example.yaml
|
||||||
|
|
||||||
|
- uses: cachix/install-nix-action@v18
|
||||||
|
if: ${{ env.ACT }} || steps.changed-files.outputs.any_changed == 'true'
|
||||||
|
|
||||||
|
- name: Run general integration tests
|
||||||
|
if: steps.changed-files.outputs.any_changed == 'true'
|
||||||
|
run: |
|
||||||
|
nix develop --command -- docker run \
|
||||||
|
--tty --rm \
|
||||||
|
--volume ~/.cache/hs-integration-go:/go \
|
||||||
|
--name headscale-test-suite \
|
||||||
|
--volume $PWD:$PWD -w $PWD/integration \
|
||||||
|
--volume /var/run/docker.sock:/var/run/docker.sock \
|
||||||
|
--volume $PWD/control_logs:/tmp/control \
|
||||||
|
golang:1 \
|
||||||
|
go test ./... \
|
||||||
|
-tags ts2019 \
|
||||||
|
-failfast \
|
||||||
|
-timeout 120m \
|
||||||
|
-parallel 1 \
|
||||||
|
-run "^TestHeadscale$"
|
||||||
|
|
||||||
|
- uses: actions/upload-artifact@v3
|
||||||
|
if: always() && steps.changed-files.outputs.any_changed == 'true'
|
||||||
|
with:
|
||||||
|
name: logs
|
||||||
|
path: "control_logs/*.log"
|
||||||
57
.github/workflows/test-integration-v2-TestOIDCAuthenticationPingAll.yaml
vendored
Normal file
57
.github/workflows/test-integration-v2-TestOIDCAuthenticationPingAll.yaml
vendored
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
# DO NOT EDIT, generated with cmd/gh-action-integration-generator/main.go
|
||||||
|
# To regenerate, run "go generate" in cmd/gh-action-integration-generator/
|
||||||
|
|
||||||
|
name: Integration Test v2 - TestOIDCAuthenticationPingAll
|
||||||
|
|
||||||
|
on: [pull_request]
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-$${{ github.head_ref || github.run_id }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
with:
|
||||||
|
fetch-depth: 2
|
||||||
|
|
||||||
|
- name: Get changed files
|
||||||
|
id: changed-files
|
||||||
|
uses: tj-actions/changed-files@v34
|
||||||
|
with:
|
||||||
|
files: |
|
||||||
|
*.nix
|
||||||
|
go.*
|
||||||
|
**/*.go
|
||||||
|
integration_test/
|
||||||
|
config-example.yaml
|
||||||
|
|
||||||
|
- uses: cachix/install-nix-action@v18
|
||||||
|
if: ${{ env.ACT }} || steps.changed-files.outputs.any_changed == 'true'
|
||||||
|
|
||||||
|
- name: Run general integration tests
|
||||||
|
if: steps.changed-files.outputs.any_changed == 'true'
|
||||||
|
run: |
|
||||||
|
nix develop --command -- docker run \
|
||||||
|
--tty --rm \
|
||||||
|
--volume ~/.cache/hs-integration-go:/go \
|
||||||
|
--name headscale-test-suite \
|
||||||
|
--volume $PWD:$PWD -w $PWD/integration \
|
||||||
|
--volume /var/run/docker.sock:/var/run/docker.sock \
|
||||||
|
--volume $PWD/control_logs:/tmp/control \
|
||||||
|
golang:1 \
|
||||||
|
go test ./... \
|
||||||
|
-tags ts2019 \
|
||||||
|
-failfast \
|
||||||
|
-timeout 120m \
|
||||||
|
-parallel 1 \
|
||||||
|
-run "^TestOIDCAuthenticationPingAll$"
|
||||||
|
|
||||||
|
- uses: actions/upload-artifact@v3
|
||||||
|
if: always() && steps.changed-files.outputs.any_changed == 'true'
|
||||||
|
with:
|
||||||
|
name: logs
|
||||||
|
path: "control_logs/*.log"
|
||||||
57
.github/workflows/test-integration-v2-TestOIDCExpireNodesBasedOnTokenExpiry.yaml
vendored
Normal file
57
.github/workflows/test-integration-v2-TestOIDCExpireNodesBasedOnTokenExpiry.yaml
vendored
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
# DO NOT EDIT, generated with cmd/gh-action-integration-generator/main.go
|
||||||
|
# To regenerate, run "go generate" in cmd/gh-action-integration-generator/
|
||||||
|
|
||||||
|
name: Integration Test v2 - TestOIDCExpireNodesBasedOnTokenExpiry
|
||||||
|
|
||||||
|
on: [pull_request]
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-$${{ github.head_ref || github.run_id }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
with:
|
||||||
|
fetch-depth: 2
|
||||||
|
|
||||||
|
- name: Get changed files
|
||||||
|
id: changed-files
|
||||||
|
uses: tj-actions/changed-files@v34
|
||||||
|
with:
|
||||||
|
files: |
|
||||||
|
*.nix
|
||||||
|
go.*
|
||||||
|
**/*.go
|
||||||
|
integration_test/
|
||||||
|
config-example.yaml
|
||||||
|
|
||||||
|
- uses: cachix/install-nix-action@v18
|
||||||
|
if: ${{ env.ACT }} || steps.changed-files.outputs.any_changed == 'true'
|
||||||
|
|
||||||
|
- name: Run general integration tests
|
||||||
|
if: steps.changed-files.outputs.any_changed == 'true'
|
||||||
|
run: |
|
||||||
|
nix develop --command -- docker run \
|
||||||
|
--tty --rm \
|
||||||
|
--volume ~/.cache/hs-integration-go:/go \
|
||||||
|
--name headscale-test-suite \
|
||||||
|
--volume $PWD:$PWD -w $PWD/integration \
|
||||||
|
--volume /var/run/docker.sock:/var/run/docker.sock \
|
||||||
|
--volume $PWD/control_logs:/tmp/control \
|
||||||
|
golang:1 \
|
||||||
|
go test ./... \
|
||||||
|
-tags ts2019 \
|
||||||
|
-failfast \
|
||||||
|
-timeout 120m \
|
||||||
|
-parallel 1 \
|
||||||
|
-run "^TestOIDCExpireNodesBasedOnTokenExpiry$"
|
||||||
|
|
||||||
|
- uses: actions/upload-artifact@v3
|
||||||
|
if: always() && steps.changed-files.outputs.any_changed == 'true'
|
||||||
|
with:
|
||||||
|
name: logs
|
||||||
|
path: "control_logs/*.log"
|
||||||
57
.github/workflows/test-integration-v2-TestPingAllByHostname.yaml
vendored
Normal file
57
.github/workflows/test-integration-v2-TestPingAllByHostname.yaml
vendored
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
# DO NOT EDIT, generated with cmd/gh-action-integration-generator/main.go
|
||||||
|
# To regenerate, run "go generate" in cmd/gh-action-integration-generator/
|
||||||
|
|
||||||
|
name: Integration Test v2 - TestPingAllByHostname
|
||||||
|
|
||||||
|
on: [pull_request]
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-$${{ github.head_ref || github.run_id }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
with:
|
||||||
|
fetch-depth: 2
|
||||||
|
|
||||||
|
- name: Get changed files
|
||||||
|
id: changed-files
|
||||||
|
uses: tj-actions/changed-files@v34
|
||||||
|
with:
|
||||||
|
files: |
|
||||||
|
*.nix
|
||||||
|
go.*
|
||||||
|
**/*.go
|
||||||
|
integration_test/
|
||||||
|
config-example.yaml
|
||||||
|
|
||||||
|
- uses: cachix/install-nix-action@v18
|
||||||
|
if: ${{ env.ACT }} || steps.changed-files.outputs.any_changed == 'true'
|
||||||
|
|
||||||
|
- name: Run general integration tests
|
||||||
|
if: steps.changed-files.outputs.any_changed == 'true'
|
||||||
|
run: |
|
||||||
|
nix develop --command -- docker run \
|
||||||
|
--tty --rm \
|
||||||
|
--volume ~/.cache/hs-integration-go:/go \
|
||||||
|
--name headscale-test-suite \
|
||||||
|
--volume $PWD:$PWD -w $PWD/integration \
|
||||||
|
--volume /var/run/docker.sock:/var/run/docker.sock \
|
||||||
|
--volume $PWD/control_logs:/tmp/control \
|
||||||
|
golang:1 \
|
||||||
|
go test ./... \
|
||||||
|
-tags ts2019 \
|
||||||
|
-failfast \
|
||||||
|
-timeout 120m \
|
||||||
|
-parallel 1 \
|
||||||
|
-run "^TestPingAllByHostname$"
|
||||||
|
|
||||||
|
- uses: actions/upload-artifact@v3
|
||||||
|
if: always() && steps.changed-files.outputs.any_changed == 'true'
|
||||||
|
with:
|
||||||
|
name: logs
|
||||||
|
path: "control_logs/*.log"
|
||||||
57
.github/workflows/test-integration-v2-TestPingAllByIP.yaml
vendored
Normal file
57
.github/workflows/test-integration-v2-TestPingAllByIP.yaml
vendored
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
# DO NOT EDIT, generated with cmd/gh-action-integration-generator/main.go
|
||||||
|
# To regenerate, run "go generate" in cmd/gh-action-integration-generator/
|
||||||
|
|
||||||
|
name: Integration Test v2 - TestPingAllByIP
|
||||||
|
|
||||||
|
on: [pull_request]
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-$${{ github.head_ref || github.run_id }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
with:
|
||||||
|
fetch-depth: 2
|
||||||
|
|
||||||
|
- name: Get changed files
|
||||||
|
id: changed-files
|
||||||
|
uses: tj-actions/changed-files@v34
|
||||||
|
with:
|
||||||
|
files: |
|
||||||
|
*.nix
|
||||||
|
go.*
|
||||||
|
**/*.go
|
||||||
|
integration_test/
|
||||||
|
config-example.yaml
|
||||||
|
|
||||||
|
- uses: cachix/install-nix-action@v18
|
||||||
|
if: ${{ env.ACT }} || steps.changed-files.outputs.any_changed == 'true'
|
||||||
|
|
||||||
|
- name: Run general integration tests
|
||||||
|
if: steps.changed-files.outputs.any_changed == 'true'
|
||||||
|
run: |
|
||||||
|
nix develop --command -- docker run \
|
||||||
|
--tty --rm \
|
||||||
|
--volume ~/.cache/hs-integration-go:/go \
|
||||||
|
--name headscale-test-suite \
|
||||||
|
--volume $PWD:$PWD -w $PWD/integration \
|
||||||
|
--volume /var/run/docker.sock:/var/run/docker.sock \
|
||||||
|
--volume $PWD/control_logs:/tmp/control \
|
||||||
|
golang:1 \
|
||||||
|
go test ./... \
|
||||||
|
-tags ts2019 \
|
||||||
|
-failfast \
|
||||||
|
-timeout 120m \
|
||||||
|
-parallel 1 \
|
||||||
|
-run "^TestPingAllByIP$"
|
||||||
|
|
||||||
|
- uses: actions/upload-artifact@v3
|
||||||
|
if: always() && steps.changed-files.outputs.any_changed == 'true'
|
||||||
|
with:
|
||||||
|
name: logs
|
||||||
|
path: "control_logs/*.log"
|
||||||
57
.github/workflows/test-integration-v2-TestPreAuthKeyCommand.yaml
vendored
Normal file
57
.github/workflows/test-integration-v2-TestPreAuthKeyCommand.yaml
vendored
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
# DO NOT EDIT, generated with cmd/gh-action-integration-generator/main.go
|
||||||
|
# To regenerate, run "go generate" in cmd/gh-action-integration-generator/
|
||||||
|
|
||||||
|
name: Integration Test v2 - TestPreAuthKeyCommand
|
||||||
|
|
||||||
|
on: [pull_request]
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-$${{ github.head_ref || github.run_id }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
with:
|
||||||
|
fetch-depth: 2
|
||||||
|
|
||||||
|
- name: Get changed files
|
||||||
|
id: changed-files
|
||||||
|
uses: tj-actions/changed-files@v34
|
||||||
|
with:
|
||||||
|
files: |
|
||||||
|
*.nix
|
||||||
|
go.*
|
||||||
|
**/*.go
|
||||||
|
integration_test/
|
||||||
|
config-example.yaml
|
||||||
|
|
||||||
|
- uses: cachix/install-nix-action@v18
|
||||||
|
if: ${{ env.ACT }} || steps.changed-files.outputs.any_changed == 'true'
|
||||||
|
|
||||||
|
- name: Run general integration tests
|
||||||
|
if: steps.changed-files.outputs.any_changed == 'true'
|
||||||
|
run: |
|
||||||
|
nix develop --command -- docker run \
|
||||||
|
--tty --rm \
|
||||||
|
--volume ~/.cache/hs-integration-go:/go \
|
||||||
|
--name headscale-test-suite \
|
||||||
|
--volume $PWD:$PWD -w $PWD/integration \
|
||||||
|
--volume /var/run/docker.sock:/var/run/docker.sock \
|
||||||
|
--volume $PWD/control_logs:/tmp/control \
|
||||||
|
golang:1 \
|
||||||
|
go test ./... \
|
||||||
|
-tags ts2019 \
|
||||||
|
-failfast \
|
||||||
|
-timeout 120m \
|
||||||
|
-parallel 1 \
|
||||||
|
-run "^TestPreAuthKeyCommand$"
|
||||||
|
|
||||||
|
- uses: actions/upload-artifact@v3
|
||||||
|
if: always() && steps.changed-files.outputs.any_changed == 'true'
|
||||||
|
with:
|
||||||
|
name: logs
|
||||||
|
path: "control_logs/*.log"
|
||||||
57
.github/workflows/test-integration-v2-TestPreAuthKeyCommandReusableEphemeral.yaml
vendored
Normal file
57
.github/workflows/test-integration-v2-TestPreAuthKeyCommandReusableEphemeral.yaml
vendored
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
# DO NOT EDIT, generated with cmd/gh-action-integration-generator/main.go
|
||||||
|
# To regenerate, run "go generate" in cmd/gh-action-integration-generator/
|
||||||
|
|
||||||
|
name: Integration Test v2 - TestPreAuthKeyCommandReusableEphemeral
|
||||||
|
|
||||||
|
on: [pull_request]
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-$${{ github.head_ref || github.run_id }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
with:
|
||||||
|
fetch-depth: 2
|
||||||
|
|
||||||
|
- name: Get changed files
|
||||||
|
id: changed-files
|
||||||
|
uses: tj-actions/changed-files@v34
|
||||||
|
with:
|
||||||
|
files: |
|
||||||
|
*.nix
|
||||||
|
go.*
|
||||||
|
**/*.go
|
||||||
|
integration_test/
|
||||||
|
config-example.yaml
|
||||||
|
|
||||||
|
- uses: cachix/install-nix-action@v18
|
||||||
|
if: ${{ env.ACT }} || steps.changed-files.outputs.any_changed == 'true'
|
||||||
|
|
||||||
|
- name: Run general integration tests
|
||||||
|
if: steps.changed-files.outputs.any_changed == 'true'
|
||||||
|
run: |
|
||||||
|
nix develop --command -- docker run \
|
||||||
|
--tty --rm \
|
||||||
|
--volume ~/.cache/hs-integration-go:/go \
|
||||||
|
--name headscale-test-suite \
|
||||||
|
--volume $PWD:$PWD -w $PWD/integration \
|
||||||
|
--volume /var/run/docker.sock:/var/run/docker.sock \
|
||||||
|
--volume $PWD/control_logs:/tmp/control \
|
||||||
|
golang:1 \
|
||||||
|
go test ./... \
|
||||||
|
-tags ts2019 \
|
||||||
|
-failfast \
|
||||||
|
-timeout 120m \
|
||||||
|
-parallel 1 \
|
||||||
|
-run "^TestPreAuthKeyCommandReusableEphemeral$"
|
||||||
|
|
||||||
|
- uses: actions/upload-artifact@v3
|
||||||
|
if: always() && steps.changed-files.outputs.any_changed == 'true'
|
||||||
|
with:
|
||||||
|
name: logs
|
||||||
|
path: "control_logs/*.log"
|
||||||
57
.github/workflows/test-integration-v2-TestPreAuthKeyCommandWithoutExpiry.yaml
vendored
Normal file
57
.github/workflows/test-integration-v2-TestPreAuthKeyCommandWithoutExpiry.yaml
vendored
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
# DO NOT EDIT, generated with cmd/gh-action-integration-generator/main.go
|
||||||
|
# To regenerate, run "go generate" in cmd/gh-action-integration-generator/
|
||||||
|
|
||||||
|
name: Integration Test v2 - TestPreAuthKeyCommandWithoutExpiry
|
||||||
|
|
||||||
|
on: [pull_request]
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-$${{ github.head_ref || github.run_id }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
with:
|
||||||
|
fetch-depth: 2
|
||||||
|
|
||||||
|
- name: Get changed files
|
||||||
|
id: changed-files
|
||||||
|
uses: tj-actions/changed-files@v34
|
||||||
|
with:
|
||||||
|
files: |
|
||||||
|
*.nix
|
||||||
|
go.*
|
||||||
|
**/*.go
|
||||||
|
integration_test/
|
||||||
|
config-example.yaml
|
||||||
|
|
||||||
|
- uses: cachix/install-nix-action@v18
|
||||||
|
if: ${{ env.ACT }} || steps.changed-files.outputs.any_changed == 'true'
|
||||||
|
|
||||||
|
- name: Run general integration tests
|
||||||
|
if: steps.changed-files.outputs.any_changed == 'true'
|
||||||
|
run: |
|
||||||
|
nix develop --command -- docker run \
|
||||||
|
--tty --rm \
|
||||||
|
--volume ~/.cache/hs-integration-go:/go \
|
||||||
|
--name headscale-test-suite \
|
||||||
|
--volume $PWD:$PWD -w $PWD/integration \
|
||||||
|
--volume /var/run/docker.sock:/var/run/docker.sock \
|
||||||
|
--volume $PWD/control_logs:/tmp/control \
|
||||||
|
golang:1 \
|
||||||
|
go test ./... \
|
||||||
|
-tags ts2019 \
|
||||||
|
-failfast \
|
||||||
|
-timeout 120m \
|
||||||
|
-parallel 1 \
|
||||||
|
-run "^TestPreAuthKeyCommandWithoutExpiry$"
|
||||||
|
|
||||||
|
- uses: actions/upload-artifact@v3
|
||||||
|
if: always() && steps.changed-files.outputs.any_changed == 'true'
|
||||||
|
with:
|
||||||
|
name: logs
|
||||||
|
path: "control_logs/*.log"
|
||||||
57
.github/workflows/test-integration-v2-TestResolveMagicDNS.yaml
vendored
Normal file
57
.github/workflows/test-integration-v2-TestResolveMagicDNS.yaml
vendored
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
# DO NOT EDIT, generated with cmd/gh-action-integration-generator/main.go
|
||||||
|
# To regenerate, run "go generate" in cmd/gh-action-integration-generator/
|
||||||
|
|
||||||
|
name: Integration Test v2 - TestResolveMagicDNS
|
||||||
|
|
||||||
|
on: [pull_request]
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-$${{ github.head_ref || github.run_id }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
with:
|
||||||
|
fetch-depth: 2
|
||||||
|
|
||||||
|
- name: Get changed files
|
||||||
|
id: changed-files
|
||||||
|
uses: tj-actions/changed-files@v34
|
||||||
|
with:
|
||||||
|
files: |
|
||||||
|
*.nix
|
||||||
|
go.*
|
||||||
|
**/*.go
|
||||||
|
integration_test/
|
||||||
|
config-example.yaml
|
||||||
|
|
||||||
|
- uses: cachix/install-nix-action@v18
|
||||||
|
if: ${{ env.ACT }} || steps.changed-files.outputs.any_changed == 'true'
|
||||||
|
|
||||||
|
- name: Run general integration tests
|
||||||
|
if: steps.changed-files.outputs.any_changed == 'true'
|
||||||
|
run: |
|
||||||
|
nix develop --command -- docker run \
|
||||||
|
--tty --rm \
|
||||||
|
--volume ~/.cache/hs-integration-go:/go \
|
||||||
|
--name headscale-test-suite \
|
||||||
|
--volume $PWD:$PWD -w $PWD/integration \
|
||||||
|
--volume /var/run/docker.sock:/var/run/docker.sock \
|
||||||
|
--volume $PWD/control_logs:/tmp/control \
|
||||||
|
golang:1 \
|
||||||
|
go test ./... \
|
||||||
|
-tags ts2019 \
|
||||||
|
-failfast \
|
||||||
|
-timeout 120m \
|
||||||
|
-parallel 1 \
|
||||||
|
-run "^TestResolveMagicDNS$"
|
||||||
|
|
||||||
|
- uses: actions/upload-artifact@v3
|
||||||
|
if: always() && steps.changed-files.outputs.any_changed == 'true'
|
||||||
|
with:
|
||||||
|
name: logs
|
||||||
|
path: "control_logs/*.log"
|
||||||
57
.github/workflows/test-integration-v2-TestSSHIsBlockedInACL.yaml
vendored
Normal file
57
.github/workflows/test-integration-v2-TestSSHIsBlockedInACL.yaml
vendored
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
# DO NOT EDIT, generated with cmd/gh-action-integration-generator/main.go
|
||||||
|
# To regenerate, run "go generate" in cmd/gh-action-integration-generator/
|
||||||
|
|
||||||
|
name: Integration Test v2 - TestSSHIsBlockedInACL
|
||||||
|
|
||||||
|
on: [pull_request]
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-$${{ github.head_ref || github.run_id }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
with:
|
||||||
|
fetch-depth: 2
|
||||||
|
|
||||||
|
- name: Get changed files
|
||||||
|
id: changed-files
|
||||||
|
uses: tj-actions/changed-files@v34
|
||||||
|
with:
|
||||||
|
files: |
|
||||||
|
*.nix
|
||||||
|
go.*
|
||||||
|
**/*.go
|
||||||
|
integration_test/
|
||||||
|
config-example.yaml
|
||||||
|
|
||||||
|
- uses: cachix/install-nix-action@v18
|
||||||
|
if: ${{ env.ACT }} || steps.changed-files.outputs.any_changed == 'true'
|
||||||
|
|
||||||
|
- name: Run general integration tests
|
||||||
|
if: steps.changed-files.outputs.any_changed == 'true'
|
||||||
|
run: |
|
||||||
|
nix develop --command -- docker run \
|
||||||
|
--tty --rm \
|
||||||
|
--volume ~/.cache/hs-integration-go:/go \
|
||||||
|
--name headscale-test-suite \
|
||||||
|
--volume $PWD:$PWD -w $PWD/integration \
|
||||||
|
--volume /var/run/docker.sock:/var/run/docker.sock \
|
||||||
|
--volume $PWD/control_logs:/tmp/control \
|
||||||
|
golang:1 \
|
||||||
|
go test ./... \
|
||||||
|
-tags ts2019 \
|
||||||
|
-failfast \
|
||||||
|
-timeout 120m \
|
||||||
|
-parallel 1 \
|
||||||
|
-run "^TestSSHIsBlockedInACL$"
|
||||||
|
|
||||||
|
- uses: actions/upload-artifact@v3
|
||||||
|
if: always() && steps.changed-files.outputs.any_changed == 'true'
|
||||||
|
with:
|
||||||
|
name: logs
|
||||||
|
path: "control_logs/*.log"
|
||||||
57
.github/workflows/test-integration-v2-TestSSHMultipleUsersAllToAll.yaml
vendored
Normal file
57
.github/workflows/test-integration-v2-TestSSHMultipleUsersAllToAll.yaml
vendored
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
# DO NOT EDIT, generated with cmd/gh-action-integration-generator/main.go
|
||||||
|
# To regenerate, run "go generate" in cmd/gh-action-integration-generator/
|
||||||
|
|
||||||
|
name: Integration Test v2 - TestSSHMultipleUsersAllToAll
|
||||||
|
|
||||||
|
on: [pull_request]
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-$${{ github.head_ref || github.run_id }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
with:
|
||||||
|
fetch-depth: 2
|
||||||
|
|
||||||
|
- name: Get changed files
|
||||||
|
id: changed-files
|
||||||
|
uses: tj-actions/changed-files@v34
|
||||||
|
with:
|
||||||
|
files: |
|
||||||
|
*.nix
|
||||||
|
go.*
|
||||||
|
**/*.go
|
||||||
|
integration_test/
|
||||||
|
config-example.yaml
|
||||||
|
|
||||||
|
- uses: cachix/install-nix-action@v18
|
||||||
|
if: ${{ env.ACT }} || steps.changed-files.outputs.any_changed == 'true'
|
||||||
|
|
||||||
|
- name: Run general integration tests
|
||||||
|
if: steps.changed-files.outputs.any_changed == 'true'
|
||||||
|
run: |
|
||||||
|
nix develop --command -- docker run \
|
||||||
|
--tty --rm \
|
||||||
|
--volume ~/.cache/hs-integration-go:/go \
|
||||||
|
--name headscale-test-suite \
|
||||||
|
--volume $PWD:$PWD -w $PWD/integration \
|
||||||
|
--volume /var/run/docker.sock:/var/run/docker.sock \
|
||||||
|
--volume $PWD/control_logs:/tmp/control \
|
||||||
|
golang:1 \
|
||||||
|
go test ./... \
|
||||||
|
-tags ts2019 \
|
||||||
|
-failfast \
|
||||||
|
-timeout 120m \
|
||||||
|
-parallel 1 \
|
||||||
|
-run "^TestSSHMultipleUsersAllToAll$"
|
||||||
|
|
||||||
|
- uses: actions/upload-artifact@v3
|
||||||
|
if: always() && steps.changed-files.outputs.any_changed == 'true'
|
||||||
|
with:
|
||||||
|
name: logs
|
||||||
|
path: "control_logs/*.log"
|
||||||
57
.github/workflows/test-integration-v2-TestSSHNoSSHConfigured.yaml
vendored
Normal file
57
.github/workflows/test-integration-v2-TestSSHNoSSHConfigured.yaml
vendored
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
# DO NOT EDIT, generated with cmd/gh-action-integration-generator/main.go
|
||||||
|
# To regenerate, run "go generate" in cmd/gh-action-integration-generator/
|
||||||
|
|
||||||
|
name: Integration Test v2 - TestSSHNoSSHConfigured
|
||||||
|
|
||||||
|
on: [pull_request]
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-$${{ github.head_ref || github.run_id }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
with:
|
||||||
|
fetch-depth: 2
|
||||||
|
|
||||||
|
- name: Get changed files
|
||||||
|
id: changed-files
|
||||||
|
uses: tj-actions/changed-files@v34
|
||||||
|
with:
|
||||||
|
files: |
|
||||||
|
*.nix
|
||||||
|
go.*
|
||||||
|
**/*.go
|
||||||
|
integration_test/
|
||||||
|
config-example.yaml
|
||||||
|
|
||||||
|
- uses: cachix/install-nix-action@v18
|
||||||
|
if: ${{ env.ACT }} || steps.changed-files.outputs.any_changed == 'true'
|
||||||
|
|
||||||
|
- name: Run general integration tests
|
||||||
|
if: steps.changed-files.outputs.any_changed == 'true'
|
||||||
|
run: |
|
||||||
|
nix develop --command -- docker run \
|
||||||
|
--tty --rm \
|
||||||
|
--volume ~/.cache/hs-integration-go:/go \
|
||||||
|
--name headscale-test-suite \
|
||||||
|
--volume $PWD:$PWD -w $PWD/integration \
|
||||||
|
--volume /var/run/docker.sock:/var/run/docker.sock \
|
||||||
|
--volume $PWD/control_logs:/tmp/control \
|
||||||
|
golang:1 \
|
||||||
|
go test ./... \
|
||||||
|
-tags ts2019 \
|
||||||
|
-failfast \
|
||||||
|
-timeout 120m \
|
||||||
|
-parallel 1 \
|
||||||
|
-run "^TestSSHNoSSHConfigured$"
|
||||||
|
|
||||||
|
- uses: actions/upload-artifact@v3
|
||||||
|
if: always() && steps.changed-files.outputs.any_changed == 'true'
|
||||||
|
with:
|
||||||
|
name: logs
|
||||||
|
path: "control_logs/*.log"
|
||||||
57
.github/workflows/test-integration-v2-TestSSHOneUserAllToAll.yaml
vendored
Normal file
57
.github/workflows/test-integration-v2-TestSSHOneUserAllToAll.yaml
vendored
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
# DO NOT EDIT, generated with cmd/gh-action-integration-generator/main.go
|
||||||
|
# To regenerate, run "go generate" in cmd/gh-action-integration-generator/
|
||||||
|
|
||||||
|
name: Integration Test v2 - TestSSHOneUserAllToAll
|
||||||
|
|
||||||
|
on: [pull_request]
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-$${{ github.head_ref || github.run_id }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
with:
|
||||||
|
fetch-depth: 2
|
||||||
|
|
||||||
|
- name: Get changed files
|
||||||
|
id: changed-files
|
||||||
|
uses: tj-actions/changed-files@v34
|
||||||
|
with:
|
||||||
|
files: |
|
||||||
|
*.nix
|
||||||
|
go.*
|
||||||
|
**/*.go
|
||||||
|
integration_test/
|
||||||
|
config-example.yaml
|
||||||
|
|
||||||
|
- uses: cachix/install-nix-action@v18
|
||||||
|
if: ${{ env.ACT }} || steps.changed-files.outputs.any_changed == 'true'
|
||||||
|
|
||||||
|
- name: Run general integration tests
|
||||||
|
if: steps.changed-files.outputs.any_changed == 'true'
|
||||||
|
run: |
|
||||||
|
nix develop --command -- docker run \
|
||||||
|
--tty --rm \
|
||||||
|
--volume ~/.cache/hs-integration-go:/go \
|
||||||
|
--name headscale-test-suite \
|
||||||
|
--volume $PWD:$PWD -w $PWD/integration \
|
||||||
|
--volume /var/run/docker.sock:/var/run/docker.sock \
|
||||||
|
--volume $PWD/control_logs:/tmp/control \
|
||||||
|
golang:1 \
|
||||||
|
go test ./... \
|
||||||
|
-tags ts2019 \
|
||||||
|
-failfast \
|
||||||
|
-timeout 120m \
|
||||||
|
-parallel 1 \
|
||||||
|
-run "^TestSSHOneUserAllToAll$"
|
||||||
|
|
||||||
|
- uses: actions/upload-artifact@v3
|
||||||
|
if: always() && steps.changed-files.outputs.any_changed == 'true'
|
||||||
|
with:
|
||||||
|
name: logs
|
||||||
|
path: "control_logs/*.log"
|
||||||
57
.github/workflows/test-integration-v2-TestSSUserOnlyIsolation.yaml
vendored
Normal file
57
.github/workflows/test-integration-v2-TestSSUserOnlyIsolation.yaml
vendored
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
# DO NOT EDIT, generated with cmd/gh-action-integration-generator/main.go
|
||||||
|
# To regenerate, run "go generate" in cmd/gh-action-integration-generator/
|
||||||
|
|
||||||
|
name: Integration Test v2 - TestSSUserOnlyIsolation
|
||||||
|
|
||||||
|
on: [pull_request]
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-$${{ github.head_ref || github.run_id }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
with:
|
||||||
|
fetch-depth: 2
|
||||||
|
|
||||||
|
- name: Get changed files
|
||||||
|
id: changed-files
|
||||||
|
uses: tj-actions/changed-files@v34
|
||||||
|
with:
|
||||||
|
files: |
|
||||||
|
*.nix
|
||||||
|
go.*
|
||||||
|
**/*.go
|
||||||
|
integration_test/
|
||||||
|
config-example.yaml
|
||||||
|
|
||||||
|
- uses: cachix/install-nix-action@v18
|
||||||
|
if: ${{ env.ACT }} || steps.changed-files.outputs.any_changed == 'true'
|
||||||
|
|
||||||
|
- name: Run general integration tests
|
||||||
|
if: steps.changed-files.outputs.any_changed == 'true'
|
||||||
|
run: |
|
||||||
|
nix develop --command -- docker run \
|
||||||
|
--tty --rm \
|
||||||
|
--volume ~/.cache/hs-integration-go:/go \
|
||||||
|
--name headscale-test-suite \
|
||||||
|
--volume $PWD:$PWD -w $PWD/integration \
|
||||||
|
--volume /var/run/docker.sock:/var/run/docker.sock \
|
||||||
|
--volume $PWD/control_logs:/tmp/control \
|
||||||
|
golang:1 \
|
||||||
|
go test ./... \
|
||||||
|
-tags ts2019 \
|
||||||
|
-failfast \
|
||||||
|
-timeout 120m \
|
||||||
|
-parallel 1 \
|
||||||
|
-run "^TestSSUserOnlyIsolation$"
|
||||||
|
|
||||||
|
- uses: actions/upload-artifact@v3
|
||||||
|
if: always() && steps.changed-files.outputs.any_changed == 'true'
|
||||||
|
with:
|
||||||
|
name: logs
|
||||||
|
path: "control_logs/*.log"
|
||||||
57
.github/workflows/test-integration-v2-TestTaildrop.yaml
vendored
Normal file
57
.github/workflows/test-integration-v2-TestTaildrop.yaml
vendored
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
# DO NOT EDIT, generated with cmd/gh-action-integration-generator/main.go
|
||||||
|
# To regenerate, run "go generate" in cmd/gh-action-integration-generator/
|
||||||
|
|
||||||
|
name: Integration Test v2 - TestTaildrop
|
||||||
|
|
||||||
|
on: [pull_request]
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-$${{ github.head_ref || github.run_id }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
with:
|
||||||
|
fetch-depth: 2
|
||||||
|
|
||||||
|
- name: Get changed files
|
||||||
|
id: changed-files
|
||||||
|
uses: tj-actions/changed-files@v34
|
||||||
|
with:
|
||||||
|
files: |
|
||||||
|
*.nix
|
||||||
|
go.*
|
||||||
|
**/*.go
|
||||||
|
integration_test/
|
||||||
|
config-example.yaml
|
||||||
|
|
||||||
|
- uses: cachix/install-nix-action@v18
|
||||||
|
if: ${{ env.ACT }} || steps.changed-files.outputs.any_changed == 'true'
|
||||||
|
|
||||||
|
- name: Run general integration tests
|
||||||
|
if: steps.changed-files.outputs.any_changed == 'true'
|
||||||
|
run: |
|
||||||
|
nix develop --command -- docker run \
|
||||||
|
--tty --rm \
|
||||||
|
--volume ~/.cache/hs-integration-go:/go \
|
||||||
|
--name headscale-test-suite \
|
||||||
|
--volume $PWD:$PWD -w $PWD/integration \
|
||||||
|
--volume /var/run/docker.sock:/var/run/docker.sock \
|
||||||
|
--volume $PWD/control_logs:/tmp/control \
|
||||||
|
golang:1 \
|
||||||
|
go test ./... \
|
||||||
|
-tags ts2019 \
|
||||||
|
-failfast \
|
||||||
|
-timeout 120m \
|
||||||
|
-parallel 1 \
|
||||||
|
-run "^TestTaildrop$"
|
||||||
|
|
||||||
|
- uses: actions/upload-artifact@v3
|
||||||
|
if: always() && steps.changed-files.outputs.any_changed == 'true'
|
||||||
|
with:
|
||||||
|
name: logs
|
||||||
|
path: "control_logs/*.log"
|
||||||
57
.github/workflows/test-integration-v2-TestTailscaleNodesJoiningHeadcale.yaml
vendored
Normal file
57
.github/workflows/test-integration-v2-TestTailscaleNodesJoiningHeadcale.yaml
vendored
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
# DO NOT EDIT, generated with cmd/gh-action-integration-generator/main.go
|
||||||
|
# To regenerate, run "go generate" in cmd/gh-action-integration-generator/
|
||||||
|
|
||||||
|
name: Integration Test v2 - TestTailscaleNodesJoiningHeadcale
|
||||||
|
|
||||||
|
on: [pull_request]
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-$${{ github.head_ref || github.run_id }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
with:
|
||||||
|
fetch-depth: 2
|
||||||
|
|
||||||
|
- name: Get changed files
|
||||||
|
id: changed-files
|
||||||
|
uses: tj-actions/changed-files@v34
|
||||||
|
with:
|
||||||
|
files: |
|
||||||
|
*.nix
|
||||||
|
go.*
|
||||||
|
**/*.go
|
||||||
|
integration_test/
|
||||||
|
config-example.yaml
|
||||||
|
|
||||||
|
- uses: cachix/install-nix-action@v18
|
||||||
|
if: ${{ env.ACT }} || steps.changed-files.outputs.any_changed == 'true'
|
||||||
|
|
||||||
|
- name: Run general integration tests
|
||||||
|
if: steps.changed-files.outputs.any_changed == 'true'
|
||||||
|
run: |
|
||||||
|
nix develop --command -- docker run \
|
||||||
|
--tty --rm \
|
||||||
|
--volume ~/.cache/hs-integration-go:/go \
|
||||||
|
--name headscale-test-suite \
|
||||||
|
--volume $PWD:$PWD -w $PWD/integration \
|
||||||
|
--volume /var/run/docker.sock:/var/run/docker.sock \
|
||||||
|
--volume $PWD/control_logs:/tmp/control \
|
||||||
|
golang:1 \
|
||||||
|
go test ./... \
|
||||||
|
-tags ts2019 \
|
||||||
|
-failfast \
|
||||||
|
-timeout 120m \
|
||||||
|
-parallel 1 \
|
||||||
|
-run "^TestTailscaleNodesJoiningHeadcale$"
|
||||||
|
|
||||||
|
- uses: actions/upload-artifact@v3
|
||||||
|
if: always() && steps.changed-files.outputs.any_changed == 'true'
|
||||||
|
with:
|
||||||
|
name: logs
|
||||||
|
path: "control_logs/*.log"
|
||||||
57
.github/workflows/test-integration-v2-TestUserCommand.yaml
vendored
Normal file
57
.github/workflows/test-integration-v2-TestUserCommand.yaml
vendored
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
# DO NOT EDIT, generated with cmd/gh-action-integration-generator/main.go
|
||||||
|
# To regenerate, run "go generate" in cmd/gh-action-integration-generator/
|
||||||
|
|
||||||
|
name: Integration Test v2 - TestUserCommand
|
||||||
|
|
||||||
|
on: [pull_request]
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-$${{ github.head_ref || github.run_id }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
with:
|
||||||
|
fetch-depth: 2
|
||||||
|
|
||||||
|
- name: Get changed files
|
||||||
|
id: changed-files
|
||||||
|
uses: tj-actions/changed-files@v34
|
||||||
|
with:
|
||||||
|
files: |
|
||||||
|
*.nix
|
||||||
|
go.*
|
||||||
|
**/*.go
|
||||||
|
integration_test/
|
||||||
|
config-example.yaml
|
||||||
|
|
||||||
|
- uses: cachix/install-nix-action@v18
|
||||||
|
if: ${{ env.ACT }} || steps.changed-files.outputs.any_changed == 'true'
|
||||||
|
|
||||||
|
- name: Run general integration tests
|
||||||
|
if: steps.changed-files.outputs.any_changed == 'true'
|
||||||
|
run: |
|
||||||
|
nix develop --command -- docker run \
|
||||||
|
--tty --rm \
|
||||||
|
--volume ~/.cache/hs-integration-go:/go \
|
||||||
|
--name headscale-test-suite \
|
||||||
|
--volume $PWD:$PWD -w $PWD/integration \
|
||||||
|
--volume /var/run/docker.sock:/var/run/docker.sock \
|
||||||
|
--volume $PWD/control_logs:/tmp/control \
|
||||||
|
golang:1 \
|
||||||
|
go test ./... \
|
||||||
|
-tags ts2019 \
|
||||||
|
-failfast \
|
||||||
|
-timeout 120m \
|
||||||
|
-parallel 1 \
|
||||||
|
-run "^TestUserCommand$"
|
||||||
|
|
||||||
|
- uses: actions/upload-artifact@v3
|
||||||
|
if: always() && steps.changed-files.outputs.any_changed == 'true'
|
||||||
|
with:
|
||||||
|
name: logs
|
||||||
|
path: "control_logs/*.log"
|
||||||
23
.github/workflows/test-integration.yml
vendored
23
.github/workflows/test-integration.yml
vendored
@@ -1,23 +0,0 @@
|
|||||||
name: CI
|
|
||||||
|
|
||||||
on: [pull_request]
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
# The "build" workflow
|
|
||||||
integration-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
|
|
||||||
|
|
||||||
# Setup Go
|
|
||||||
- name: Setup Go
|
|
||||||
uses: actions/setup-go@v2
|
|
||||||
with:
|
|
||||||
go-version: "1.16.3"
|
|
||||||
|
|
||||||
- name: Run Integration tests
|
|
||||||
run: go test -tags integration -timeout 30m
|
|
||||||
43
.github/workflows/test.yml
vendored
43
.github/workflows/test.yml
vendored
@@ -1,33 +1,34 @@
|
|||||||
name: CI
|
name: Tests
|
||||||
|
|
||||||
on: [push, pull_request]
|
on: [push, pull_request]
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-$${{ github.head_ref || github.run_id }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
# The "build" workflow
|
|
||||||
test:
|
test:
|
||||||
# The type of runner that the job will run on
|
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
# Steps represent a sequence of tasks that will be executed as part of the job
|
|
||||||
steps:
|
steps:
|
||||||
# Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it
|
- uses: actions/checkout@v3
|
||||||
- uses: actions/checkout@v2
|
|
||||||
|
|
||||||
# Setup Go
|
|
||||||
- name: Setup Go
|
|
||||||
uses: actions/setup-go@v2
|
|
||||||
with:
|
with:
|
||||||
go-version: "1.16.3" # The Go version to download (if necessary) and use.
|
fetch-depth: 2
|
||||||
|
|
||||||
# Install all the dependencies
|
- name: Get changed files
|
||||||
- name: Install dependencies
|
id: changed-files
|
||||||
run: |
|
uses: tj-actions/changed-files@v34
|
||||||
go version
|
with:
|
||||||
sudo apt update
|
files: |
|
||||||
sudo apt install -y make
|
*.nix
|
||||||
|
go.*
|
||||||
|
**/*.go
|
||||||
|
integration_test/
|
||||||
|
config-example.yaml
|
||||||
|
|
||||||
|
- uses: cachix/install-nix-action@v16
|
||||||
|
if: steps.changed-files.outputs.any_changed == 'true'
|
||||||
|
|
||||||
- name: Run tests
|
- name: Run tests
|
||||||
run: make test
|
if: steps.changed-files.outputs.any_changed == 'true'
|
||||||
|
run: nix develop --check
|
||||||
- name: Run build
|
|
||||||
run: make
|
|
||||||
|
|||||||
13
.gitignore
vendored
13
.gitignore
vendored
@@ -14,8 +14,12 @@
|
|||||||
# Dependency directories (remove the comment below to include it)
|
# Dependency directories (remove the comment below to include it)
|
||||||
# vendor/
|
# vendor/
|
||||||
|
|
||||||
|
dist/
|
||||||
/headscale
|
/headscale
|
||||||
config.json
|
config.json
|
||||||
|
config.yaml
|
||||||
|
derp.yaml
|
||||||
|
*.hujson
|
||||||
*.key
|
*.key
|
||||||
/db.sqlite
|
/db.sqlite
|
||||||
*.sqlite3
|
*.sqlite3
|
||||||
@@ -23,4 +27,11 @@ config.json
|
|||||||
# Exclude Jetbrains Editors
|
# Exclude Jetbrains Editors
|
||||||
.idea
|
.idea
|
||||||
|
|
||||||
test_output/
|
test_output/
|
||||||
|
control_logs/
|
||||||
|
|
||||||
|
# Nix build output
|
||||||
|
result
|
||||||
|
.direnv/
|
||||||
|
|
||||||
|
integration_test/etc/config.dump.yaml
|
||||||
|
|||||||
74
.golangci.yaml
Normal file
74
.golangci.yaml
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
---
|
||||||
|
run:
|
||||||
|
timeout: 10m
|
||||||
|
build-tags:
|
||||||
|
- ts2019
|
||||||
|
|
||||||
|
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
|
||||||
|
- execinquery
|
||||||
|
- exhaustruct
|
||||||
|
- nolintlint
|
||||||
|
- musttag # causes issues with imported libs
|
||||||
|
|
||||||
|
# deprecated
|
||||||
|
- structcheck # replaced by unused
|
||||||
|
- ifshort # deprecated by the owner
|
||||||
|
- varcheck # replaced by unused
|
||||||
|
- nosnakecase # replaced by revive
|
||||||
|
- deadcode # replaced by unused
|
||||||
|
|
||||||
|
# We should strive to enable these:
|
||||||
|
- wrapcheck
|
||||||
|
- dupl
|
||||||
|
- makezero
|
||||||
|
- maintidx
|
||||||
|
|
||||||
|
# Limits the methods of an interface to 10. We have more in integration tests
|
||||||
|
- interfacebloat
|
||||||
|
|
||||||
|
# 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
|
||||||
138
.goreleaser.yml
138
.goreleaser.yml
@@ -1,90 +1,94 @@
|
|||||||
# This is an example .goreleaser.yml file with some sane defaults.
|
---
|
||||||
# Make sure to check the documentation at http://goreleaser.com
|
|
||||||
before:
|
before:
|
||||||
hooks:
|
hooks:
|
||||||
- go mod tidy
|
- go mod tidy -compat=1.20
|
||||||
|
|
||||||
|
release:
|
||||||
|
prerelease: auto
|
||||||
|
|
||||||
builds:
|
builds:
|
||||||
- id: darwin-amd64
|
- id: headscale
|
||||||
main: ./cmd/headscale/headscale.go
|
main: ./cmd/headscale/headscale.go
|
||||||
mod_timestamp: '{{ .CommitTimestamp }}'
|
mod_timestamp: "{{ .CommitTimestamp }}"
|
||||||
goos:
|
|
||||||
- darwin
|
|
||||||
goarch:
|
|
||||||
- amd64
|
|
||||||
env:
|
env:
|
||||||
- PKG_CONFIG_SYSROOT_DIR=/sysroot/macos/amd64
|
- CGO_ENABLED=0
|
||||||
- PKG_CONFIG_PATH=/sysroot/macos/amd64/usr/local/lib/pkgconfig
|
targets:
|
||||||
- CC=o64-clang
|
- darwin_amd64
|
||||||
- CXX=o64-clang++
|
- darwin_arm64
|
||||||
|
- freebsd_amd64
|
||||||
|
- linux_386
|
||||||
|
- linux_amd64
|
||||||
|
- linux_arm64
|
||||||
|
- linux_arm_5
|
||||||
|
- linux_arm_6
|
||||||
|
- linux_arm_7
|
||||||
flags:
|
flags:
|
||||||
- -mod=readonly
|
- -mod=readonly
|
||||||
ldflags:
|
ldflags:
|
||||||
- -s -w -X github.com/juanfont/headscale/cmd/headscale/cli.Version=v{{.Version}}
|
- -s -w -X github.com/juanfont/headscale/cmd/headscale/cli.Version=v{{.Version}}
|
||||||
|
tags:
|
||||||
- id: linux-armhf
|
- ts2019
|
||||||
main: ./cmd/headscale/headscale.go
|
|
||||||
mod_timestamp: '{{ .CommitTimestamp }}'
|
|
||||||
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
|
|
||||||
flags:
|
|
||||||
- -mod=readonly
|
|
||||||
ldflags:
|
|
||||||
- -s -w -X github.com/juanfont/headscale/cmd/headscale/cli.Version=v{{.Version}}
|
|
||||||
|
|
||||||
|
|
||||||
- id: linux-amd64
|
|
||||||
env:
|
|
||||||
- CGO_ENABLED=1
|
|
||||||
goos:
|
|
||||||
- linux
|
|
||||||
goarch:
|
|
||||||
- amd64
|
|
||||||
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
|
|
||||||
goos:
|
|
||||||
- linux
|
|
||||||
goarch:
|
|
||||||
- arm64
|
|
||||||
env:
|
|
||||||
- CGO_ENABLED=1
|
|
||||||
- CC=aarch64-linux-gnu-gcc
|
|
||||||
main: ./cmd/headscale/headscale.go
|
|
||||||
mod_timestamp: '{{ .CommitTimestamp }}'
|
|
||||||
ldflags:
|
|
||||||
- -s -w -X github.com/juanfont/headscale/cmd/headscale/cli.Version=v{{.Version}}
|
|
||||||
|
|
||||||
archives:
|
archives:
|
||||||
- id: golang-cross
|
- id: golang-cross
|
||||||
builds:
|
builds:
|
||||||
- darwin-amd64
|
- darwin_amd64
|
||||||
- linux-armhf
|
- darwin_arm64
|
||||||
- linux-amd64
|
- freebsd_amd64
|
||||||
- linux-arm64
|
- linux_386
|
||||||
|
- linux_amd64
|
||||||
|
- linux_arm64
|
||||||
|
- linux_arm_5
|
||||||
|
- linux_arm_6
|
||||||
|
- linux_arm_7
|
||||||
name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}"
|
name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}"
|
||||||
format: binary
|
format: binary
|
||||||
|
|
||||||
|
nfpms:
|
||||||
|
# Configure nFPM for .deb and .rpm releases
|
||||||
|
#
|
||||||
|
# See https://nfpm.goreleaser.com/configuration/
|
||||||
|
# and https://goreleaser.com/customization/nfpm/
|
||||||
|
#
|
||||||
|
# Useful tools for debugging .debs:
|
||||||
|
# List file contents: dpkg -c dist/headscale...deb
|
||||||
|
# Package metadata: dpkg --info dist/headscale....deb
|
||||||
|
#
|
||||||
|
- builds:
|
||||||
|
- headscale
|
||||||
|
package_name: headscale
|
||||||
|
priority: optional
|
||||||
|
vendor: headscale
|
||||||
|
maintainer: Kristoffer Dalby <kristoffer@dalby.cc>
|
||||||
|
homepage: https://github.com/juanfont/headscale
|
||||||
|
license: BSD
|
||||||
|
bindir: /usr/bin
|
||||||
|
formats:
|
||||||
|
- deb
|
||||||
|
- rpm
|
||||||
|
contents:
|
||||||
|
- src: ./config-example.yaml
|
||||||
|
dst: /etc/headscale/config.yaml
|
||||||
|
type: config|noreplace
|
||||||
|
file_info:
|
||||||
|
mode: 0644
|
||||||
|
- src: ./docs/packaging/headscale.systemd.service
|
||||||
|
dst: /etc/systemd/system/headscale.service
|
||||||
|
- dst: /var/lib/headscale
|
||||||
|
type: dir
|
||||||
|
- dst: /var/run/headscale
|
||||||
|
type: dir
|
||||||
|
scripts:
|
||||||
|
postinstall: ./docs/packaging/postinstall.sh
|
||||||
|
postremove: ./docs/packaging/postremove.sh
|
||||||
|
|
||||||
checksum:
|
checksum:
|
||||||
name_template: 'checksums.txt'
|
name_template: "checksums.txt"
|
||||||
snapshot:
|
snapshot:
|
||||||
name_template: "{{ .Tag }}-next"
|
name_template: "{{ .Tag }}-next"
|
||||||
changelog:
|
changelog:
|
||||||
sort: asc
|
sort: asc
|
||||||
filters:
|
filters:
|
||||||
exclude:
|
exclude:
|
||||||
- '^docs:'
|
- "^docs:"
|
||||||
- '^test:'
|
- "^test:"
|
||||||
|
|||||||
1
.prettierignore
Normal file
1
.prettierignore
Normal file
@@ -0,0 +1 @@
|
|||||||
|
.github/workflows/test-integration-v2*
|
||||||
303
CHANGELOG.md
Normal file
303
CHANGELOG.md
Normal file
@@ -0,0 +1,303 @@
|
|||||||
|
# CHANGELOG
|
||||||
|
|
||||||
|
## 0.22.0 (2023-XX-XX)
|
||||||
|
|
||||||
|
### Changes
|
||||||
|
|
||||||
|
- Add `.deb` and `.rpm` packages to release process [#1297](https://github.com/juanfont/headscale/pull/1297)
|
||||||
|
- Add 32-bit Arm platforms to release process [#1297](https://github.com/juanfont/headscale/pull/1297)
|
||||||
|
- Fix longstanding bug that would prevent "\*" from working properly in ACLs (issue [#699](https://github.com/juanfont/headscale/issues/699)) [#1279](https://github.com/juanfont/headscale/pull/1279)
|
||||||
|
- Target Go 1.20 and Tailscale 1.38 for Headscale [#1323](https://github.com/juanfont/headscale/pull/1323)
|
||||||
|
|
||||||
|
## 0.21.0 (2023-03-20)
|
||||||
|
|
||||||
|
### Changes
|
||||||
|
|
||||||
|
- Adding "configtest" CLI command. [#1230](https://github.com/juanfont/headscale/pull/1230)
|
||||||
|
- Add documentation on connecting with iOS to `/apple` [#1261](https://github.com/juanfont/headscale/pull/1261)
|
||||||
|
- Update iOS compatibility and added documentation for iOS [#1264](https://github.com/juanfont/headscale/pull/1264)
|
||||||
|
- Allow to delete routes [#1244](https://github.com/juanfont/headscale/pull/1244)
|
||||||
|
|
||||||
|
## 0.20.0 (2023-02-03)
|
||||||
|
|
||||||
|
### Changes
|
||||||
|
|
||||||
|
- Fix wrong behaviour in exit nodes [#1159](https://github.com/juanfont/headscale/pull/1159)
|
||||||
|
- Align behaviour of `dns_config.restricted_nameservers` to tailscale [#1162](https://github.com/juanfont/headscale/pull/1162)
|
||||||
|
- Make OpenID Connect authenticated client expiry time configurable [#1191](https://github.com/juanfont/headscale/pull/1191)
|
||||||
|
- defaults to 180 days like Tailscale SaaS
|
||||||
|
- adds option to use the expiry time from the OpenID token for the node (see config-example.yaml)
|
||||||
|
- Set ControlTime in Map info sent to nodes [#1195](https://github.com/juanfont/headscale/pull/1195)
|
||||||
|
- Populate Tags field on Node updates sent [#1195](https://github.com/juanfont/headscale/pull/1195)
|
||||||
|
|
||||||
|
## 0.19.0 (2023-01-29)
|
||||||
|
|
||||||
|
### BREAKING
|
||||||
|
|
||||||
|
- Rename Namespace to User [#1144](https://github.com/juanfont/headscale/pull/1144)
|
||||||
|
- **BACKUP your database before upgrading**
|
||||||
|
- Command line flags previously taking `--namespace` or `-n` will now require `--user` or `-u`
|
||||||
|
|
||||||
|
## 0.18.0 (2023-01-14)
|
||||||
|
|
||||||
|
### Changes
|
||||||
|
|
||||||
|
- Reworked routing and added support for subnet router failover [#1024](https://github.com/juanfont/headscale/pull/1024)
|
||||||
|
- Added an OIDC AllowGroups Configuration options and authorization check [#1041](https://github.com/juanfont/headscale/pull/1041)
|
||||||
|
- Set `db_ssl` to false by default [#1052](https://github.com/juanfont/headscale/pull/1052)
|
||||||
|
- Fix duplicate nodes due to incorrect implementation of the protocol [#1058](https://github.com/juanfont/headscale/pull/1058)
|
||||||
|
- Report if a machine is online in CLI more accurately [#1062](https://github.com/juanfont/headscale/pull/1062)
|
||||||
|
- Added config option for custom DNS records [#1035](https://github.com/juanfont/headscale/pull/1035)
|
||||||
|
- Expire nodes based on OIDC token expiry [#1067](https://github.com/juanfont/headscale/pull/1067)
|
||||||
|
- Remove ephemeral nodes on logout [#1098](https://github.com/juanfont/headscale/pull/1098)
|
||||||
|
- Performance improvements in ACLs [#1129](https://github.com/juanfont/headscale/pull/1129)
|
||||||
|
- OIDC client secret can be passed via a file [#1127](https://github.com/juanfont/headscale/pull/1127)
|
||||||
|
|
||||||
|
## 0.17.1 (2022-12-05)
|
||||||
|
|
||||||
|
### Changes
|
||||||
|
|
||||||
|
- Correct typo on macOS standalone profile link [#1028](https://github.com/juanfont/headscale/pull/1028)
|
||||||
|
- Update platform docs with Fast User Switching [#1016](https://github.com/juanfont/headscale/pull/1016)
|
||||||
|
|
||||||
|
## 0.17.0 (2022-11-26)
|
||||||
|
|
||||||
|
### BREAKING
|
||||||
|
|
||||||
|
- `noise.private_key_path` has been added and is required for the new noise protocol.
|
||||||
|
- Log level option `log_level` was moved to a distinct `log` config section and renamed to `level` [#768](https://github.com/juanfont/headscale/pull/768)
|
||||||
|
- Removed Alpine Linux container image [#962](https://github.com/juanfont/headscale/pull/962)
|
||||||
|
|
||||||
|
### Important Changes
|
||||||
|
|
||||||
|
- Added support for Tailscale TS2021 protocol [#738](https://github.com/juanfont/headscale/pull/738)
|
||||||
|
- Add experimental support for [SSH ACL](https://tailscale.com/kb/1018/acls/#tailscale-ssh) (see docs for limitations) [#847](https://github.com/juanfont/headscale/pull/847)
|
||||||
|
- Please note that this support should be considered _partially_ implemented
|
||||||
|
- SSH ACLs status:
|
||||||
|
- Support `accept` and `check` (SSH can be enabled and used for connecting and authentication)
|
||||||
|
- Rejecting connections **are not supported**, meaning that if you enable SSH, then assume that _all_ `ssh` connections **will be allowed**.
|
||||||
|
- If you decied to try this feature, please carefully managed permissions by blocking port `22` with regular ACLs or do _not_ set `--ssh` on your clients.
|
||||||
|
- We are currently improving our testing of the SSH ACLs, help us get an overview by testing and giving feedback.
|
||||||
|
- This feature should be considered dangerous and it is disabled by default. Enable by setting `HEADSCALE_EXPERIMENTAL_FEATURE_SSH=1`.
|
||||||
|
|
||||||
|
### Changes
|
||||||
|
|
||||||
|
- Add ability to specify config location via env var `HEADSCALE_CONFIG` [#674](https://github.com/juanfont/headscale/issues/674)
|
||||||
|
- Target Go 1.19 for Headscale [#778](https://github.com/juanfont/headscale/pull/778)
|
||||||
|
- Target Tailscale v1.30.0 to build Headscale [#780](https://github.com/juanfont/headscale/pull/780)
|
||||||
|
- Give a warning when running Headscale with reverse proxy improperly configured for WebSockets [#788](https://github.com/juanfont/headscale/pull/788)
|
||||||
|
- Fix subnet routers with Primary Routes [#811](https://github.com/juanfont/headscale/pull/811)
|
||||||
|
- Added support for JSON logs [#653](https://github.com/juanfont/headscale/issues/653)
|
||||||
|
- Sanitise the node key passed to registration url [#823](https://github.com/juanfont/headscale/pull/823)
|
||||||
|
- Add support for generating pre-auth keys with tags [#767](https://github.com/juanfont/headscale/pull/767)
|
||||||
|
- Add support for evaluating `autoApprovers` ACL entries when a machine is registered [#763](https://github.com/juanfont/headscale/pull/763)
|
||||||
|
- Add config flag to allow Headscale to start if OIDC provider is down [#829](https://github.com/juanfont/headscale/pull/829)
|
||||||
|
- Fix prefix length comparison bug in AutoApprovers route evaluation [#862](https://github.com/juanfont/headscale/pull/862)
|
||||||
|
- Random node DNS suffix only applied if names collide in namespace. [#766](https://github.com/juanfont/headscale/issues/766)
|
||||||
|
- Remove `ip_prefix` configuration option and warning [#899](https://github.com/juanfont/headscale/pull/899)
|
||||||
|
- Add `dns_config.override_local_dns` option [#905](https://github.com/juanfont/headscale/pull/905)
|
||||||
|
- Fix some DNS config issues [#660](https://github.com/juanfont/headscale/issues/660)
|
||||||
|
- Make it possible to disable TS2019 with build flag [#928](https://github.com/juanfont/headscale/pull/928)
|
||||||
|
- Fix OIDC registration issues [#960](https://github.com/juanfont/headscale/pull/960) and [#971](https://github.com/juanfont/headscale/pull/971)
|
||||||
|
- Add support for specifying NextDNS DNS-over-HTTPS resolver [#940](https://github.com/juanfont/headscale/pull/940)
|
||||||
|
- Make more sslmode available for postgresql connection [#927](https://github.com/juanfont/headscale/pull/927)
|
||||||
|
|
||||||
|
## 0.16.4 (2022-08-21)
|
||||||
|
|
||||||
|
### Changes
|
||||||
|
|
||||||
|
- Add ability to connect to PostgreSQL over TLS/SSL [#745](https://github.com/juanfont/headscale/pull/745)
|
||||||
|
- Fix CLI registration of expired machines [#754](https://github.com/juanfont/headscale/pull/754)
|
||||||
|
|
||||||
|
## 0.16.3 (2022-08-17)
|
||||||
|
|
||||||
|
### Changes
|
||||||
|
|
||||||
|
- Fix issue with OIDC authentication [#747](https://github.com/juanfont/headscale/pull/747)
|
||||||
|
|
||||||
|
## 0.16.2 (2022-08-14)
|
||||||
|
|
||||||
|
### Changes
|
||||||
|
|
||||||
|
- Fixed bugs in the client registration process after migration to NodeKey [#735](https://github.com/juanfont/headscale/pull/735)
|
||||||
|
|
||||||
|
## 0.16.1 (2022-08-12)
|
||||||
|
|
||||||
|
### Changes
|
||||||
|
|
||||||
|
- Updated dependencies (including the library that lacked armhf support) [#722](https://github.com/juanfont/headscale/pull/722)
|
||||||
|
- Fix missing group expansion in function `excludeCorretlyTaggedNodes` [#563](https://github.com/juanfont/headscale/issues/563)
|
||||||
|
- Improve registration protocol implementation and switch to NodeKey as main identifier [#725](https://github.com/juanfont/headscale/pull/725)
|
||||||
|
- Add ability to connect to PostgreSQL via unix socket [#734](https://github.com/juanfont/headscale/pull/734)
|
||||||
|
|
||||||
|
## 0.16.0 (2022-07-25)
|
||||||
|
|
||||||
|
**Note:** Take a backup of your database before upgrading.
|
||||||
|
|
||||||
|
### BREAKING
|
||||||
|
|
||||||
|
- Old ACL syntax is no longer supported ("users" & "ports" -> "src" & "dst"). Please check [the new syntax](https://tailscale.com/kb/1018/acls/).
|
||||||
|
|
||||||
|
### Changes
|
||||||
|
|
||||||
|
- **Drop** armhf (32-bit ARM) support. [#609](https://github.com/juanfont/headscale/pull/609)
|
||||||
|
- Headscale fails to serve if the ACL policy file cannot be parsed [#537](https://github.com/juanfont/headscale/pull/537)
|
||||||
|
- Fix labels cardinality error when registering unknown pre-auth key [#519](https://github.com/juanfont/headscale/pull/519)
|
||||||
|
- Fix send on closed channel crash in polling [#542](https://github.com/juanfont/headscale/pull/542)
|
||||||
|
- Fixed spurious calls to setLastStateChangeToNow from ephemeral nodes [#566](https://github.com/juanfont/headscale/pull/566)
|
||||||
|
- Add command for moving nodes between namespaces [#362](https://github.com/juanfont/headscale/issues/362)
|
||||||
|
- Added more configuration parameters for OpenID Connect (scopes, free-form paramters, domain and user allowlist)
|
||||||
|
- Add command to set tags on a node [#525](https://github.com/juanfont/headscale/issues/525)
|
||||||
|
- Add command to view tags of nodes [#356](https://github.com/juanfont/headscale/issues/356)
|
||||||
|
- Add --all (-a) flag to enable routes command [#360](https://github.com/juanfont/headscale/issues/360)
|
||||||
|
- Fix issue where nodes was not updated across namespaces [#560](https://github.com/juanfont/headscale/pull/560)
|
||||||
|
- Add the ability to rename a nodes name [#560](https://github.com/juanfont/headscale/pull/560)
|
||||||
|
- Node DNS names are now unique, a random suffix will be added when a node joins
|
||||||
|
- This change contains database changes, remember to **backup** your database before upgrading
|
||||||
|
- Add option to enable/disable logtail (Tailscale's logging infrastructure) [#596](https://github.com/juanfont/headscale/pull/596)
|
||||||
|
- This change disables the logs by default
|
||||||
|
- Use [Prometheus]'s duration parser, supporting days (`d`), weeks (`w`) and years (`y`) [#598](https://github.com/juanfont/headscale/pull/598)
|
||||||
|
- Add support for reloading ACLs with SIGHUP [#601](https://github.com/juanfont/headscale/pull/601)
|
||||||
|
- Use new ACL syntax [#618](https://github.com/juanfont/headscale/pull/618)
|
||||||
|
- Add -c option to specify config file from command line [#285](https://github.com/juanfont/headscale/issues/285) [#612](https://github.com/juanfont/headscale/pull/601)
|
||||||
|
- Add configuration option to allow Tailscale clients to use a random WireGuard port. [kb/1181/firewalls](https://tailscale.com/kb/1181/firewalls) [#624](https://github.com/juanfont/headscale/pull/624)
|
||||||
|
- Improve obtuse UX regarding missing configuration (`ephemeral_node_inactivity_timeout` not set) [#639](https://github.com/juanfont/headscale/pull/639)
|
||||||
|
- Fix nodes being shown as 'offline' in `tailscale status` [#648](https://github.com/juanfont/headscale/pull/648)
|
||||||
|
- Improve shutdown behaviour [#651](https://github.com/juanfont/headscale/pull/651)
|
||||||
|
- Drop Gin as web framework in Headscale [648](https://github.com/juanfont/headscale/pull/648) [677](https://github.com/juanfont/headscale/pull/677)
|
||||||
|
- Make tailnet node updates check interval configurable [#675](https://github.com/juanfont/headscale/pull/675)
|
||||||
|
- Fix regression with HTTP API [#684](https://github.com/juanfont/headscale/pull/684)
|
||||||
|
- nodes ls now print both Hostname and Name(Issue [#647](https://github.com/juanfont/headscale/issues/647) PR [#687](https://github.com/juanfont/headscale/pull/687))
|
||||||
|
|
||||||
|
## 0.15.0 (2022-03-20)
|
||||||
|
|
||||||
|
**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 (and STUN) 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)
|
||||||
|
- Fix a bug that prevented the use of `tailscale logout` with OIDC [#508](https://github.com/juanfont/headscale/issues/508)
|
||||||
|
- Added Tailscale repo HEAD and unstable releases channel to the integration tests targets [#513](https://github.com/juanfont/headscale/pull/513)
|
||||||
|
|
||||||
|
## 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)
|
||||||
134
CODE_OF_CONDUCT.md
Normal file
134
CODE_OF_CONDUCT.md
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
# Contributor Covenant Code of Conduct
|
||||||
|
|
||||||
|
## Our Pledge
|
||||||
|
|
||||||
|
We as members, contributors, and leaders pledge to make participation
|
||||||
|
in our community a harassment-free experience for everyone, regardless
|
||||||
|
of age, body size, visible or invisible disability, ethnicity, sex
|
||||||
|
characteristics, gender identity and expression, level of experience,
|
||||||
|
education, socio-economic status, nationality, personal appearance,
|
||||||
|
race, religion, or sexual identity and orientation.
|
||||||
|
|
||||||
|
We pledge to act and interact in ways that contribute to an open,
|
||||||
|
welcoming, diverse, inclusive, and healthy community.
|
||||||
|
|
||||||
|
## Our Standards
|
||||||
|
|
||||||
|
Examples of behavior that contributes to a positive environment for
|
||||||
|
our community include:
|
||||||
|
|
||||||
|
- Demonstrating empathy and kindness toward other people
|
||||||
|
- Being respectful of differing opinions, viewpoints, and experiences
|
||||||
|
- Giving and gracefully accepting constructive feedback
|
||||||
|
- Accepting responsibility and apologizing to those affected by our
|
||||||
|
mistakes, and learning from the experience
|
||||||
|
- Focusing on what is best not just for us as individuals, but for the
|
||||||
|
overall community
|
||||||
|
|
||||||
|
Examples of unacceptable behavior include:
|
||||||
|
|
||||||
|
- The use of sexualized language or imagery, and sexual attention or
|
||||||
|
advances of any kind
|
||||||
|
- Trolling, insulting or derogatory comments, and personal or
|
||||||
|
political attacks
|
||||||
|
- Public or private harassment
|
||||||
|
- Publishing others' private information, such as a physical or email
|
||||||
|
address, without their explicit permission
|
||||||
|
- Other conduct which could reasonably be considered inappropriate in
|
||||||
|
a professional setting
|
||||||
|
|
||||||
|
## Enforcement Responsibilities
|
||||||
|
|
||||||
|
Community leaders are responsible for clarifying and enforcing our
|
||||||
|
standards of acceptable behavior and will take appropriate and fair
|
||||||
|
corrective action in response to any behavior that they deem
|
||||||
|
inappropriate, threatening, offensive, or harmful.
|
||||||
|
|
||||||
|
Community leaders have the right and responsibility to remove, edit,
|
||||||
|
or reject comments, commits, code, wiki edits, issues, and other
|
||||||
|
contributions that are not aligned to this Code of Conduct, and will
|
||||||
|
communicate reasons for moderation decisions when appropriate.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
This Code of Conduct applies within all community spaces, and also
|
||||||
|
applies when an individual is officially representing the community in
|
||||||
|
public spaces. Examples of representing our community include using an
|
||||||
|
official e-mail address, posting via an official social media account,
|
||||||
|
or acting as an appointed representative at an online or offline
|
||||||
|
event.
|
||||||
|
|
||||||
|
## Enforcement
|
||||||
|
|
||||||
|
Instances of abusive, harassing, or otherwise unacceptable behavior
|
||||||
|
may be reported to the community leaders responsible for enforcement
|
||||||
|
at our Discord channel. All complaints
|
||||||
|
will be reviewed and investigated promptly and fairly.
|
||||||
|
|
||||||
|
All community leaders are obligated to respect the privacy and
|
||||||
|
security of the reporter of any incident.
|
||||||
|
|
||||||
|
## Enforcement Guidelines
|
||||||
|
|
||||||
|
Community leaders will follow these Community Impact Guidelines in
|
||||||
|
determining the consequences for any action they deem in violation of
|
||||||
|
this Code of Conduct:
|
||||||
|
|
||||||
|
### 1. Correction
|
||||||
|
|
||||||
|
**Community Impact**: Use of inappropriate language or other behavior
|
||||||
|
deemed unprofessional or unwelcome in the community.
|
||||||
|
|
||||||
|
**Consequence**: A private, written warning from community leaders,
|
||||||
|
providing clarity around the nature of the violation and an
|
||||||
|
explanation of why the behavior was inappropriate. A public apology
|
||||||
|
may be requested.
|
||||||
|
|
||||||
|
### 2. Warning
|
||||||
|
|
||||||
|
**Community Impact**: A violation through a single incident or series
|
||||||
|
of actions.
|
||||||
|
|
||||||
|
**Consequence**: A warning with consequences for continued
|
||||||
|
behavior. No interaction with the people involved, including
|
||||||
|
unsolicited interaction with those enforcing the Code of Conduct, for
|
||||||
|
a specified period of time. This includes avoiding interactions in
|
||||||
|
community spaces as well as external channels like social
|
||||||
|
media. Violating these terms may lead to a temporary or permanent ban.
|
||||||
|
|
||||||
|
### 3. Temporary Ban
|
||||||
|
|
||||||
|
**Community Impact**: A serious violation of community standards,
|
||||||
|
including sustained inappropriate behavior.
|
||||||
|
|
||||||
|
**Consequence**: A temporary ban from any sort of interaction or
|
||||||
|
public communication with the community for a specified period of
|
||||||
|
time. No public or private interaction with the people involved,
|
||||||
|
including unsolicited interaction with those enforcing the Code of
|
||||||
|
Conduct, is allowed during this period. Violating these terms may lead
|
||||||
|
to a permanent ban.
|
||||||
|
|
||||||
|
### 4. Permanent Ban
|
||||||
|
|
||||||
|
**Community Impact**: Demonstrating a pattern of violation of
|
||||||
|
community standards, including sustained inappropriate behavior,
|
||||||
|
harassment of an individual, or aggression toward or disparagement of
|
||||||
|
classes of individuals.
|
||||||
|
|
||||||
|
**Consequence**: A permanent ban from any sort of public interaction
|
||||||
|
within the community.
|
||||||
|
|
||||||
|
## Attribution
|
||||||
|
|
||||||
|
This Code of Conduct is adapted from the [Contributor
|
||||||
|
Covenant][homepage], version 2.0, available at
|
||||||
|
https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
|
||||||
|
|
||||||
|
Community Impact Guidelines were inspired by [Mozilla's code of
|
||||||
|
conduct enforcement ladder](https://github.com/mozilla/diversity).
|
||||||
|
|
||||||
|
[homepage]: https://www.contributor-covenant.org
|
||||||
|
|
||||||
|
For answers to common questions about this code of conduct, see the
|
||||||
|
FAQ at https://www.contributor-covenant.org/faq. Translations are
|
||||||
|
available at https://www.contributor-covenant.org/translations.
|
||||||
16
Dockerfile
16
Dockerfile
@@ -1,18 +1,22 @@
|
|||||||
FROM golang:1.17.1-bullseye AS build
|
# Builder image
|
||||||
|
FROM docker.io/golang:1.20-bullseye AS build
|
||||||
|
ARG VERSION=dev
|
||||||
ENV GOPATH /go
|
ENV GOPATH /go
|
||||||
|
WORKDIR /go/src/headscale
|
||||||
|
|
||||||
COPY go.mod go.sum /go/src/headscale/
|
COPY go.mod go.sum /go/src/headscale/
|
||||||
WORKDIR /go/src/headscale
|
|
||||||
RUN go mod download
|
RUN go mod download
|
||||||
|
|
||||||
COPY . /go/src/headscale
|
COPY . .
|
||||||
|
|
||||||
RUN go install -a -ldflags="-extldflags=-static" -tags netgo,sqlite_omit_load_extension ./cmd/headscale
|
RUN CGO_ENABLED=0 GOOS=linux go install -tags ts2019 -ldflags="-s -w -X github.com/juanfont/headscale/cmd/headscale/cli.Version=$VERSION" -a ./cmd/headscale
|
||||||
|
RUN strip /go/bin/headscale
|
||||||
RUN test -e /go/bin/headscale
|
RUN test -e /go/bin/headscale
|
||||||
|
|
||||||
FROM ubuntu:20.04
|
# Production image
|
||||||
|
FROM gcr.io/distroless/base-debian11
|
||||||
|
|
||||||
COPY --from=build /go/bin/headscale /usr/local/bin/headscale
|
COPY --from=build /go/bin/headscale /bin/headscale
|
||||||
ENV TZ UTC
|
ENV TZ UTC
|
||||||
|
|
||||||
EXPOSE 8080/tcp
|
EXPOSE 8080/tcp
|
||||||
|
|||||||
24
Dockerfile.debug
Normal file
24
Dockerfile.debug
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# Builder image
|
||||||
|
FROM docker.io/golang:1.20-bullseye AS build
|
||||||
|
ARG VERSION=dev
|
||||||
|
ENV GOPATH /go
|
||||||
|
WORKDIR /go/src/headscale
|
||||||
|
|
||||||
|
COPY go.mod go.sum /go/src/headscale/
|
||||||
|
RUN go mod download
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
RUN CGO_ENABLED=0 GOOS=linux go install -tags ts2019 -ldflags="-s -w -X github.com/juanfont/headscale/cmd/headscale/cli.Version=$VERSION" -a ./cmd/headscale
|
||||||
|
RUN test -e /go/bin/headscale
|
||||||
|
|
||||||
|
# Debug image
|
||||||
|
FROM docker.io/golang:1.20.0-bullseye
|
||||||
|
|
||||||
|
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"]
|
||||||
@@ -1,11 +1,19 @@
|
|||||||
FROM ubuntu:latest
|
FROM ubuntu:latest
|
||||||
|
|
||||||
ARG TAILSCALE_VERSION
|
ARG TAILSCALE_VERSION=*
|
||||||
|
ARG TAILSCALE_CHANNEL=stable
|
||||||
|
|
||||||
RUN apt-get update \
|
RUN apt-get update \
|
||||||
&& apt-get install -y gnupg curl \
|
&& apt-get install -y gnupg curl ssh \
|
||||||
&& curl -fsSL https://pkgs.tailscale.com/stable/ubuntu/focal.gpg | apt-key add - \
|
&& curl -fsSL https://pkgs.tailscale.com/${TAILSCALE_CHANNEL}/ubuntu/focal.gpg | apt-key add - \
|
||||||
&& curl -fsSL https://pkgs.tailscale.com/stable/ubuntu/focal.list | tee /etc/apt/sources.list.d/tailscale.list \
|
&& curl -fsSL https://pkgs.tailscale.com/${TAILSCALE_CHANNEL}/ubuntu/focal.list | tee /etc/apt/sources.list.d/tailscale.list \
|
||||||
&& apt-get update \
|
&& apt-get update \
|
||||||
&& apt-get install -y tailscale=${TAILSCALE_VERSION} \
|
&& apt-get install -y ca-certificates tailscale=${TAILSCALE_VERSION} dnsutils \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
RUN adduser --shell=/bin/bash ssh-it-user
|
||||||
|
|
||||||
|
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
|
||||||
|
|||||||
24
Dockerfile.tailscale-HEAD
Normal file
24
Dockerfile.tailscale-HEAD
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
FROM golang:latest
|
||||||
|
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y ca-certificates dnsutils git iptables ssh \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
RUN useradd --shell=/bin/bash --create-home ssh-it-user
|
||||||
|
|
||||||
|
RUN git clone https://github.com/tailscale/tailscale.git
|
||||||
|
|
||||||
|
WORKDIR /go/tailscale
|
||||||
|
|
||||||
|
RUN git checkout main
|
||||||
|
|
||||||
|
RUN sh build_dist.sh tailscale.com/cmd/tailscale
|
||||||
|
RUN sh build_dist.sh tailscale.com/cmd/tailscaled
|
||||||
|
|
||||||
|
RUN cp tailscale /usr/local/bin/
|
||||||
|
RUN cp tailscaled /usr/local/bin/
|
||||||
|
|
||||||
|
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
|
||||||
77
Makefile
77
Makefile
@@ -1,16 +1,62 @@
|
|||||||
# Calculate version
|
# Calculate version
|
||||||
version = $(shell ./scripts/version-at-commit.sh)
|
version ?= $(shell git describe --always --tags --dirty)
|
||||||
|
|
||||||
|
rwildcard=$(foreach d,$(wildcard $1*),$(call rwildcard,$d/,$2) $(filter $(subst *,%,$2),$d))
|
||||||
|
|
||||||
|
# Determine if OS supports pie
|
||||||
|
GOOS ?= $(shell uname | tr '[:upper:]' '[:lower:]')
|
||||||
|
ifeq ($(filter $(GOOS), openbsd netbsd soloaris plan9), )
|
||||||
|
pieflags = -buildmode=pie
|
||||||
|
else
|
||||||
|
endif
|
||||||
|
|
||||||
|
TAGS = -tags ts2019
|
||||||
|
|
||||||
|
# GO_SOURCES = $(wildcard *.go)
|
||||||
|
# PROTO_SOURCES = $(wildcard **/*.proto)
|
||||||
|
GO_SOURCES = $(call rwildcard,,*.go)
|
||||||
|
PROTO_SOURCES = $(call rwildcard,,*.proto)
|
||||||
|
|
||||||
|
|
||||||
build:
|
build:
|
||||||
go build -ldflags "-s -w -X github.com/juanfont/headscale/cmd/headscale/cli.Version=$(version)" cmd/headscale/headscale.go
|
nix build
|
||||||
|
|
||||||
dev: lint test build
|
dev: lint test build
|
||||||
|
|
||||||
test:
|
test:
|
||||||
@go test -coverprofile=coverage.out ./...
|
@go test $(TAGS) -short -coverprofile=coverage.out ./...
|
||||||
|
|
||||||
test_integration:
|
test_integration: test_integration_cli test_integration_derp test_integration_v2_general
|
||||||
go test -tags integration -timeout 30m ./...
|
|
||||||
|
test_integration_cli:
|
||||||
|
docker network rm $$(docker network ls --filter name=headscale --quiet) || true
|
||||||
|
docker network create headscale-test || true
|
||||||
|
docker run -t --rm \
|
||||||
|
--network headscale-test \
|
||||||
|
-v ~/.cache/hs-integration-go:/go \
|
||||||
|
-v $$PWD:$$PWD -w $$PWD \
|
||||||
|
-v /var/run/docker.sock:/var/run/docker.sock golang:1 \
|
||||||
|
go test $(TAGS) -failfast -timeout 30m -count=1 -run IntegrationCLI ./...
|
||||||
|
|
||||||
|
test_integration_derp:
|
||||||
|
docker network rm $$(docker network ls --filter name=headscale --quiet) || true
|
||||||
|
docker network create headscale-test || true
|
||||||
|
docker run -t --rm \
|
||||||
|
--network headscale-test \
|
||||||
|
-v ~/.cache/hs-integration-go:/go \
|
||||||
|
-v $$PWD:$$PWD -w $$PWD \
|
||||||
|
-v /var/run/docker.sock:/var/run/docker.sock golang:1 \
|
||||||
|
go test $(TAGS) -failfast -timeout 30m -count=1 -run IntegrationDERP ./...
|
||||||
|
|
||||||
|
test_integration_v2_general:
|
||||||
|
docker run \
|
||||||
|
-t --rm \
|
||||||
|
-v ~/.cache/hs-integration-go:/go \
|
||||||
|
--name headscale-test-suite \
|
||||||
|
-v $$PWD:$$PWD -w $$PWD/integration \
|
||||||
|
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||||
|
golang:1 \
|
||||||
|
go test $(TAGS) -failfast ./... -timeout 120m -parallel 8
|
||||||
|
|
||||||
coverprofile_func:
|
coverprofile_func:
|
||||||
go tool cover -func=coverage.out
|
go tool cover -func=coverage.out
|
||||||
@@ -19,9 +65,26 @@ coverprofile_html:
|
|||||||
go tool cover -html=coverage.out
|
go tool cover -html=coverage.out
|
||||||
|
|
||||||
lint:
|
lint:
|
||||||
golint
|
golangci-lint run --fix --timeout 10m
|
||||||
golangci-lint run --timeout 5m
|
|
||||||
|
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/ && go run github.com/bufbuild/buf/cmd/buf lint
|
||||||
|
|
||||||
compress: build
|
compress: build
|
||||||
upx --brute headscale
|
upx --brute headscale
|
||||||
|
|
||||||
|
generate:
|
||||||
|
rm -rf gen
|
||||||
|
go run github.com/bufbuild/buf/cmd/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
|
||||||
|
|||||||
790
acls.go
790
acls.go
@@ -2,29 +2,70 @@ package headscale
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"net/netip"
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/rs/zerolog/log"
|
"github.com/rs/zerolog/log"
|
||||||
|
|
||||||
"github.com/tailscale/hujson"
|
"github.com/tailscale/hujson"
|
||||||
"inet.af/netaddr"
|
"go4.org/netipx"
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
"tailscale.com/envknob"
|
||||||
"tailscale.com/tailcfg"
|
"tailscale.com/tailcfg"
|
||||||
)
|
)
|
||||||
|
|
||||||
const errorEmptyPolicy = Error("empty policy")
|
const (
|
||||||
const errorInvalidAction = Error("invalid action")
|
errEmptyPolicy = Error("empty policy")
|
||||||
const errorInvalidUserSection = Error("invalid user section")
|
errInvalidAction = Error("invalid action")
|
||||||
const errorInvalidGroup = Error("invalid group")
|
errInvalidGroup = Error("invalid group")
|
||||||
const errorInvalidTag = Error("invalid tag")
|
errInvalidTag = Error("invalid tag")
|
||||||
const errorInvalidNamespace = Error("invalid namespace")
|
errInvalidPortFormat = Error("invalid port format")
|
||||||
const errorInvalidPortFormat = Error("invalid port format")
|
errWildcardIsNeeded = Error("wildcard as port is required for the protocol")
|
||||||
|
)
|
||||||
|
|
||||||
// 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
|
||||||
|
)
|
||||||
|
|
||||||
|
// For some reason golang.org/x/net/internal/iana is an internal package.
|
||||||
|
const (
|
||||||
|
protocolICMP = 1 // Internet Control Message
|
||||||
|
protocolIGMP = 2 // Internet Group Management
|
||||||
|
protocolIPv4 = 4 // IPv4 encapsulation
|
||||||
|
protocolTCP = 6 // Transmission Control
|
||||||
|
protocolEGP = 8 // Exterior Gateway Protocol
|
||||||
|
protocolIGP = 9 // any private interior gateway (used by Cisco for their IGRP)
|
||||||
|
protocolUDP = 17 // User Datagram
|
||||||
|
protocolGRE = 47 // Generic Routing Encapsulation
|
||||||
|
protocolESP = 50 // Encap Security Payload
|
||||||
|
protocolAH = 51 // Authentication Header
|
||||||
|
protocolIPv6ICMP = 58 // ICMP for IPv6
|
||||||
|
protocolSCTP = 132 // Stream Control Transmission Protocol
|
||||||
|
ProtocolFC = 133 // Fibre Channel
|
||||||
|
)
|
||||||
|
|
||||||
|
var featureEnableSSH = envknob.RegisterBool("HEADSCALE_EXPERIMENTAL_FEATURE_SSH")
|
||||||
|
|
||||||
|
// LoadACLPolicy loads the ACL policy from the specify path, and generates the ACL rules.
|
||||||
func (h *Headscale) LoadACLPolicy(path string) error {
|
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)
|
policyFile, err := os.Open(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -32,77 +73,343 @@ func (h *Headscale) LoadACLPolicy(path string) error {
|
|||||||
defer policyFile.Close()
|
defer policyFile.Close()
|
||||||
|
|
||||||
var policy ACLPolicy
|
var policy ACLPolicy
|
||||||
b, err := io.ReadAll(policyFile)
|
policyBytes, err := io.ReadAll(policyFile)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
err = hujson.Unmarshal(b, &policy)
|
|
||||||
if err != nil {
|
switch filepath.Ext(path) {
|
||||||
return err
|
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() {
|
if policy.IsZero() {
|
||||||
return errorEmptyPolicy
|
return errEmptyPolicy
|
||||||
}
|
}
|
||||||
|
|
||||||
h.aclPolicy = &policy
|
h.aclPolicy = &policy
|
||||||
rules, err := h.generateACLRules()
|
|
||||||
|
return h.UpdateACLRules()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Headscale) UpdateACLRules() error {
|
||||||
|
machines, err := h.ListMachines()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if h.aclPolicy == nil {
|
||||||
|
return errEmptyPolicy
|
||||||
|
}
|
||||||
|
|
||||||
|
rules, err := generateACLRules(machines, *h.aclPolicy, h.cfg.OIDC.StripEmaildomain)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
log.Trace().Interface("ACL", rules).Msg("ACL rules generated")
|
||||||
h.aclRules = rules
|
h.aclRules = rules
|
||||||
|
|
||||||
|
// Precompute a map of which sources can reach each destination, this is
|
||||||
|
// to provide quicker lookup when we calculate the peerlist for the map
|
||||||
|
// response to nodes.
|
||||||
|
aclPeerCacheMap := generateACLPeerCacheMap(rules)
|
||||||
|
h.aclPeerCacheMapRW.Lock()
|
||||||
|
h.aclPeerCacheMap = aclPeerCacheMap
|
||||||
|
h.aclPeerCacheMapRW.Unlock()
|
||||||
|
|
||||||
|
if featureEnableSSH() {
|
||||||
|
sshRules, err := h.generateSSHRules()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
log.Trace().Interface("SSH", sshRules).Msg("SSH rules generated")
|
||||||
|
if h.sshPolicy == nil {
|
||||||
|
h.sshPolicy = &tailcfg.SSHPolicy{}
|
||||||
|
}
|
||||||
|
h.sshPolicy.Rules = sshRules
|
||||||
|
} else if h.aclPolicy != nil && len(h.aclPolicy.SSHs) > 0 {
|
||||||
|
log.Info().Msg("SSH ACLs has been defined, but HEADSCALE_EXPERIMENTAL_FEATURE_SSH is not enabled, this is a unstable feature, check docs before activating")
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Headscale) generateACLRules() (*[]tailcfg.FilterRule, error) {
|
// generateACLPeerCacheMap takes a list of Tailscale filter rules and generates a map
|
||||||
|
// of which Sources ("*" and IPs) can access destinations. This is to speed up the
|
||||||
|
// process of generating MapResponses when deciding which Peers to inform nodes about.
|
||||||
|
func generateACLPeerCacheMap(rules []tailcfg.FilterRule) map[string]map[string]struct{} {
|
||||||
|
aclCachePeerMap := make(map[string]map[string]struct{})
|
||||||
|
for _, rule := range rules {
|
||||||
|
for _, srcIP := range rule.SrcIPs {
|
||||||
|
for _, ip := range expandACLPeerAddr(srcIP) {
|
||||||
|
if data, ok := aclCachePeerMap[ip]; ok {
|
||||||
|
for _, dstPort := range rule.DstPorts {
|
||||||
|
for _, dstIP := range expandACLPeerAddr(dstPort.IP) {
|
||||||
|
data[dstIP] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
dstPortsMap := make(map[string]struct{}, len(rule.DstPorts))
|
||||||
|
for _, dstPort := range rule.DstPorts {
|
||||||
|
for _, dstIP := range expandACLPeerAddr(dstPort.IP) {
|
||||||
|
dstPortsMap[dstIP] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
aclCachePeerMap[ip] = dstPortsMap
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Trace().Interface("ACL Cache Map", aclCachePeerMap).Msg("ACL Peer Cache Map generated")
|
||||||
|
|
||||||
|
return aclCachePeerMap
|
||||||
|
}
|
||||||
|
|
||||||
|
// expandACLPeerAddr takes a "tailcfg.FilterRule" "IP" and expands it into
|
||||||
|
// something our cache logic can look up, which is "*" or single IP addresses.
|
||||||
|
// This is probably quite inefficient, but it is a result of
|
||||||
|
// "make it work, then make it fast", and a lot of the ACL stuff does not
|
||||||
|
// work, but people have tried to make it fast.
|
||||||
|
func expandACLPeerAddr(srcIP string) []string {
|
||||||
|
if ip, err := netip.ParseAddr(srcIP); err == nil {
|
||||||
|
return []string{ip.String()}
|
||||||
|
}
|
||||||
|
|
||||||
|
if cidr, err := netip.ParsePrefix(srcIP); err == nil {
|
||||||
|
addrs := []string{}
|
||||||
|
|
||||||
|
ipRange := netipx.RangeOfPrefix(cidr)
|
||||||
|
|
||||||
|
from := ipRange.From()
|
||||||
|
too := ipRange.To()
|
||||||
|
|
||||||
|
if from == too {
|
||||||
|
return []string{from.String()}
|
||||||
|
}
|
||||||
|
|
||||||
|
for from != too && from.Less(too) {
|
||||||
|
addrs = append(addrs, from.String())
|
||||||
|
from = from.Next()
|
||||||
|
}
|
||||||
|
addrs = append(addrs, too.String()) // Add the last IP address in the range
|
||||||
|
|
||||||
|
return addrs
|
||||||
|
}
|
||||||
|
|
||||||
|
// probably "*" or other string based "IP"
|
||||||
|
return []string{srcIP}
|
||||||
|
}
|
||||||
|
|
||||||
|
func generateACLRules(
|
||||||
|
machines []Machine,
|
||||||
|
aclPolicy ACLPolicy,
|
||||||
|
stripEmaildomain bool,
|
||||||
|
) ([]tailcfg.FilterRule, error) {
|
||||||
rules := []tailcfg.FilterRule{}
|
rules := []tailcfg.FilterRule{}
|
||||||
|
|
||||||
for i, a := range h.aclPolicy.ACLs {
|
for index, acl := range aclPolicy.ACLs {
|
||||||
if a.Action != "accept" {
|
if acl.Action != "accept" {
|
||||||
return nil, errorInvalidAction
|
return nil, errInvalidAction
|
||||||
}
|
}
|
||||||
|
|
||||||
r := tailcfg.FilterRule{}
|
|
||||||
|
|
||||||
srcIPs := []string{}
|
srcIPs := []string{}
|
||||||
for j, u := range a.Users {
|
for innerIndex, src := range acl.Sources {
|
||||||
srcs, err := h.generateACLPolicySrcIP(u)
|
srcs, err := generateACLPolicySrc(machines, aclPolicy, src, stripEmaildomain)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error().
|
log.Error().
|
||||||
Msgf("Error parsing ACL %d, User %d", i, j)
|
Msgf("Error parsing ACL %d, Source %d", index, innerIndex)
|
||||||
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
srcIPs = append(srcIPs, *srcs...)
|
srcIPs = append(srcIPs, srcs...)
|
||||||
|
}
|
||||||
|
|
||||||
|
protocols, needsWildcard, err := parseProtocol(acl.Protocol)
|
||||||
|
if err != nil {
|
||||||
|
log.Error().
|
||||||
|
Msgf("Error parsing ACL %d. protocol unknown %s", index, acl.Protocol)
|
||||||
|
|
||||||
|
return nil, err
|
||||||
}
|
}
|
||||||
r.SrcIPs = srcIPs
|
|
||||||
|
|
||||||
destPorts := []tailcfg.NetPortRange{}
|
destPorts := []tailcfg.NetPortRange{}
|
||||||
for j, d := range a.Ports {
|
for innerIndex, dest := range acl.Destinations {
|
||||||
dests, err := h.generateACLPolicyDestPorts(d)
|
dests, err := generateACLPolicyDest(
|
||||||
|
machines,
|
||||||
|
aclPolicy,
|
||||||
|
dest,
|
||||||
|
needsWildcard,
|
||||||
|
stripEmaildomain,
|
||||||
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error().
|
log.Error().
|
||||||
Msgf("Error parsing ACL %d, Port %d", i, j)
|
Msgf("Error parsing ACL %d, Destination %d", index, innerIndex)
|
||||||
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
destPorts = append(destPorts, *dests...)
|
destPorts = append(destPorts, dests...)
|
||||||
}
|
}
|
||||||
|
|
||||||
rules = append(rules, tailcfg.FilterRule{
|
rules = append(rules, tailcfg.FilterRule{
|
||||||
SrcIPs: srcIPs,
|
SrcIPs: srcIPs,
|
||||||
DstPorts: destPorts,
|
DstPorts: destPorts,
|
||||||
|
IPProto: protocols,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return &rules, nil
|
return rules, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Headscale) generateACLPolicySrcIP(u string) (*[]string, error) {
|
func (h *Headscale) generateSSHRules() ([]*tailcfg.SSHRule, error) {
|
||||||
return h.expandAlias(u)
|
rules := []*tailcfg.SSHRule{}
|
||||||
|
|
||||||
|
if h.aclPolicy == nil {
|
||||||
|
return nil, errEmptyPolicy
|
||||||
|
}
|
||||||
|
|
||||||
|
machines, err := h.ListMachines()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
acceptAction := tailcfg.SSHAction{
|
||||||
|
Message: "",
|
||||||
|
Reject: false,
|
||||||
|
Accept: true,
|
||||||
|
SessionDuration: 0,
|
||||||
|
AllowAgentForwarding: false,
|
||||||
|
HoldAndDelegate: "",
|
||||||
|
AllowLocalPortForwarding: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
rejectAction := tailcfg.SSHAction{
|
||||||
|
Message: "",
|
||||||
|
Reject: true,
|
||||||
|
Accept: false,
|
||||||
|
SessionDuration: 0,
|
||||||
|
AllowAgentForwarding: false,
|
||||||
|
HoldAndDelegate: "",
|
||||||
|
AllowLocalPortForwarding: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
for index, sshACL := range h.aclPolicy.SSHs {
|
||||||
|
action := rejectAction
|
||||||
|
switch sshACL.Action {
|
||||||
|
case "accept":
|
||||||
|
action = acceptAction
|
||||||
|
case "check":
|
||||||
|
checkAction, err := sshCheckAction(sshACL.CheckPeriod)
|
||||||
|
if err != nil {
|
||||||
|
log.Error().
|
||||||
|
Msgf("Error parsing SSH %d, check action with unparsable duration '%s'", index, sshACL.CheckPeriod)
|
||||||
|
} else {
|
||||||
|
action = *checkAction
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
log.Error().
|
||||||
|
Msgf("Error parsing SSH %d, unknown action '%s'", index, sshACL.Action)
|
||||||
|
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
principals := make([]*tailcfg.SSHPrincipal, 0, len(sshACL.Sources))
|
||||||
|
for innerIndex, rawSrc := range sshACL.Sources {
|
||||||
|
expandedSrcs, err := expandAlias(
|
||||||
|
machines,
|
||||||
|
*h.aclPolicy,
|
||||||
|
rawSrc,
|
||||||
|
h.cfg.OIDC.StripEmaildomain,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
log.Error().
|
||||||
|
Msgf("Error parsing SSH %d, Source %d", index, innerIndex)
|
||||||
|
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for _, expandedSrc := range expandedSrcs {
|
||||||
|
principals = append(principals, &tailcfg.SSHPrincipal{
|
||||||
|
NodeIP: expandedSrc,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
userMap := make(map[string]string, len(sshACL.Users))
|
||||||
|
for _, user := range sshACL.Users {
|
||||||
|
userMap[user] = "="
|
||||||
|
}
|
||||||
|
rules = append(rules, &tailcfg.SSHRule{
|
||||||
|
RuleExpires: nil,
|
||||||
|
Principals: principals,
|
||||||
|
SSHUsers: userMap,
|
||||||
|
Action: &action,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return rules, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Headscale) generateACLPolicyDestPorts(d string) (*[]tailcfg.NetPortRange, error) {
|
func sshCheckAction(duration string) (*tailcfg.SSHAction, error) {
|
||||||
tokens := strings.Split(d, ":")
|
sessionLength, err := time.ParseDuration(duration)
|
||||||
if len(tokens) < 2 || len(tokens) > 3 {
|
if err != nil {
|
||||||
return nil, errorInvalidPortFormat
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &tailcfg.SSHAction{
|
||||||
|
Message: "",
|
||||||
|
Reject: false,
|
||||||
|
Accept: true,
|
||||||
|
SessionDuration: sessionLength,
|
||||||
|
AllowAgentForwarding: false,
|
||||||
|
HoldAndDelegate: "",
|
||||||
|
AllowLocalPortForwarding: true,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func generateACLPolicySrc(
|
||||||
|
machines []Machine,
|
||||||
|
aclPolicy ACLPolicy,
|
||||||
|
src string,
|
||||||
|
stripEmaildomain bool,
|
||||||
|
) ([]string, error) {
|
||||||
|
return expandAlias(machines, aclPolicy, src, stripEmaildomain)
|
||||||
|
}
|
||||||
|
|
||||||
|
func generateACLPolicyDest(
|
||||||
|
machines []Machine,
|
||||||
|
aclPolicy ACLPolicy,
|
||||||
|
dest string,
|
||||||
|
needsWildcard bool,
|
||||||
|
stripEmaildomain bool,
|
||||||
|
) ([]tailcfg.NetPortRange, error) {
|
||||||
|
tokens := strings.Split(dest, ":")
|
||||||
|
if len(tokens) < expectedTokenItems || len(tokens) > 3 {
|
||||||
|
return nil, errInvalidPortFormat
|
||||||
}
|
}
|
||||||
|
|
||||||
var alias string
|
var alias string
|
||||||
@@ -112,23 +419,28 @@ func (h *Headscale) generateACLPolicyDestPorts(d string) (*[]tailcfg.NetPortRang
|
|||||||
// tag:montreal-webserver:80,443
|
// tag:montreal-webserver:80,443
|
||||||
// tag:api-server:443
|
// tag:api-server:443
|
||||||
// example-host-1:*
|
// example-host-1:*
|
||||||
if len(tokens) == 2 {
|
if len(tokens) == expectedTokenItems {
|
||||||
alias = tokens[0]
|
alias = tokens[0]
|
||||||
} else {
|
} else {
|
||||||
alias = fmt.Sprintf("%s:%s", tokens[0], tokens[1])
|
alias = fmt.Sprintf("%s:%s", tokens[0], tokens[1])
|
||||||
}
|
}
|
||||||
|
|
||||||
expanded, err := h.expandAlias(alias)
|
expanded, err := expandAlias(
|
||||||
|
machines,
|
||||||
|
aclPolicy,
|
||||||
|
alias,
|
||||||
|
stripEmaildomain,
|
||||||
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
ports, err := h.expandPorts(tokens[len(tokens)-1])
|
ports, err := expandPorts(tokens[len(tokens)-1], needsWildcard)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
dests := []tailcfg.NetPortRange{}
|
dests := []tailcfg.NetPortRange{}
|
||||||
for _, d := range *expanded {
|
for _, d := range expanded {
|
||||||
for _, p := range *ports {
|
for _, p := range *ports {
|
||||||
pr := tailcfg.NetPortRange{
|
pr := tailcfg.NetPortRange{
|
||||||
IP: d,
|
IP: d,
|
||||||
@@ -137,120 +449,241 @@ func (h *Headscale) generateACLPolicyDestPorts(d string) (*[]tailcfg.NetPortRang
|
|||||||
dests = append(dests, pr)
|
dests = append(dests, pr)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return &dests, nil
|
|
||||||
|
return dests, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Headscale) expandAlias(s string) (*[]string, error) {
|
// parseProtocol reads the proto field of the ACL and generates a list of
|
||||||
if s == "*" {
|
// protocols that will be allowed, following the IANA IP protocol number
|
||||||
return &[]string{"*"}, nil
|
// https://www.iana.org/assignments/protocol-numbers/protocol-numbers.xhtml
|
||||||
}
|
//
|
||||||
|
// If the ACL proto field is empty, it allows ICMPv4, ICMPv6, TCP, and UDP,
|
||||||
|
// as per Tailscale behaviour (see tailcfg.FilterRule).
|
||||||
|
//
|
||||||
|
// Also returns a boolean indicating if the protocol
|
||||||
|
// requires all the destinations to use wildcard as port number (only TCP,
|
||||||
|
// UDP and SCTP support specifying ports).
|
||||||
|
func parseProtocol(protocol string) ([]int, bool, error) {
|
||||||
|
switch protocol {
|
||||||
|
case "":
|
||||||
|
return nil, false, nil
|
||||||
|
case "igmp":
|
||||||
|
return []int{protocolIGMP}, true, nil
|
||||||
|
case "ipv4", "ip-in-ip":
|
||||||
|
return []int{protocolIPv4}, true, nil
|
||||||
|
case "tcp":
|
||||||
|
return []int{protocolTCP}, false, nil
|
||||||
|
case "egp":
|
||||||
|
return []int{protocolEGP}, true, nil
|
||||||
|
case "igp":
|
||||||
|
return []int{protocolIGP}, true, nil
|
||||||
|
case "udp":
|
||||||
|
return []int{protocolUDP}, false, nil
|
||||||
|
case "gre":
|
||||||
|
return []int{protocolGRE}, true, nil
|
||||||
|
case "esp":
|
||||||
|
return []int{protocolESP}, true, nil
|
||||||
|
case "ah":
|
||||||
|
return []int{protocolAH}, true, nil
|
||||||
|
case "sctp":
|
||||||
|
return []int{protocolSCTP}, false, nil
|
||||||
|
case "icmp":
|
||||||
|
return []int{protocolICMP, protocolIPv6ICMP}, true, nil
|
||||||
|
|
||||||
if strings.HasPrefix(s, "group:") {
|
default:
|
||||||
if _, ok := h.aclPolicy.Groups[s]; !ok {
|
protocolNumber, err := strconv.Atoi(protocol)
|
||||||
return nil, errorInvalidGroup
|
|
||||||
}
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return &ips, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if strings.HasPrefix(s, "tag:") {
|
|
||||||
if _, ok := h.aclPolicy.TagOwners[s]; !ok {
|
|
||||||
return nil, errorInvalidTag
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return &ips, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
n, err := h.GetNamespace(s)
|
|
||||||
if err == nil {
|
|
||||||
nodes, err := h.ListMachinesInNamespace(n.Name)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, false, err
|
||||||
}
|
}
|
||||||
ips := []string{}
|
needsWildcard := protocolNumber != protocolTCP &&
|
||||||
for _, n := range *nodes {
|
protocolNumber != protocolUDP &&
|
||||||
ips = append(ips, n.IPAddress)
|
protocolNumber != protocolSCTP
|
||||||
}
|
|
||||||
return &ips, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if h, ok := h.aclPolicy.Hosts[s]; ok {
|
return []int{protocolNumber}, needsWildcard, nil
|
||||||
return &[]string{h.String()}, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
ip, err := netaddr.ParseIP(s)
|
|
||||||
if err == nil {
|
|
||||||
return &[]string{ip.String()}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
cidr, err := netaddr.ParseIPPrefix(s)
|
|
||||||
if err == nil {
|
|
||||||
return &[]string{cidr.String()}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil, errorInvalidUserSection
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Headscale) expandPorts(s string) (*[]tailcfg.PortRange, error) {
|
// expandalias has an input of either
|
||||||
if s == "*" {
|
// - a user
|
||||||
return &[]tailcfg.PortRange{{First: 0, Last: 65535}}, nil
|
// - a group
|
||||||
|
// - a tag
|
||||||
|
// - a host
|
||||||
|
// and transform these in IPAddresses.
|
||||||
|
func expandAlias(
|
||||||
|
machines []Machine,
|
||||||
|
aclPolicy ACLPolicy,
|
||||||
|
alias string,
|
||||||
|
stripEmailDomain bool,
|
||||||
|
) ([]string, error) {
|
||||||
|
ips := []string{}
|
||||||
|
if alias == "*" {
|
||||||
|
return []string{"*"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Debug().
|
||||||
|
Str("alias", alias).
|
||||||
|
Msg("Expanding")
|
||||||
|
|
||||||
|
if strings.HasPrefix(alias, "group:") {
|
||||||
|
users, err := expandGroup(aclPolicy, alias, stripEmailDomain)
|
||||||
|
if err != nil {
|
||||||
|
return ips, err
|
||||||
|
}
|
||||||
|
for _, n := range users {
|
||||||
|
nodes := filterMachinesByUser(machines, n)
|
||||||
|
for _, node := range nodes {
|
||||||
|
ips = append(ips, node.IPAddresses.ToStringSlice()...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ips, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.HasPrefix(alias, "tag:") {
|
||||||
|
// check for forced tags
|
||||||
|
for _, machine := range machines {
|
||||||
|
if contains(machine.ForcedTags, alias) {
|
||||||
|
ips = append(ips, machine.IPAddresses.ToStringSlice()...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// find tag owners
|
||||||
|
owners, err := expandTagOwners(aclPolicy, alias, stripEmailDomain)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, errInvalidTag) {
|
||||||
|
if len(ips) == 0 {
|
||||||
|
return ips, fmt.Errorf(
|
||||||
|
"%w. %v isn't owned by a TagOwner and no forced tags are defined",
|
||||||
|
errInvalidTag,
|
||||||
|
alias,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return ips, nil
|
||||||
|
} else {
|
||||||
|
return ips, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// filter out machines per tag owner
|
||||||
|
for _, user := range owners {
|
||||||
|
machines := filterMachinesByUser(machines, user)
|
||||||
|
for _, machine := range machines {
|
||||||
|
hi := machine.GetHostInfo()
|
||||||
|
if contains(hi.RequestTags, alias) {
|
||||||
|
ips = append(ips, machine.IPAddresses.ToStringSlice()...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ips, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// if alias is a user
|
||||||
|
nodes := filterMachinesByUser(machines, alias)
|
||||||
|
nodes = excludeCorrectlyTaggedNodes(aclPolicy, nodes, alias, stripEmailDomain)
|
||||||
|
|
||||||
|
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 := netip.ParseAddr(alias)
|
||||||
|
if err == nil {
|
||||||
|
return []string{ip.String()}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// if alias is an CIDR
|
||||||
|
cidr, err := netip.ParsePrefix(alias)
|
||||||
|
if err == nil {
|
||||||
|
return []string{cidr.String()}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Warn().Msgf("No IPs found with the alias %v", alias)
|
||||||
|
|
||||||
|
return ips, 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 user
|
||||||
|
// we assume in this function that we only have nodes from 1 user.
|
||||||
|
func excludeCorrectlyTaggedNodes(
|
||||||
|
aclPolicy ACLPolicy,
|
||||||
|
nodes []Machine,
|
||||||
|
user string,
|
||||||
|
stripEmailDomain bool,
|
||||||
|
) []Machine {
|
||||||
|
out := []Machine{}
|
||||||
|
tags := []string{}
|
||||||
|
for tag := range aclPolicy.TagOwners {
|
||||||
|
owners, _ := expandTagOwners(aclPolicy, user, stripEmailDomain)
|
||||||
|
ns := append(owners, user)
|
||||||
|
if contains(ns, user) {
|
||||||
|
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 contains(tags, t) {
|
||||||
|
found = true
|
||||||
|
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(machine.ForcedTags) > 0 {
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
out = append(out, machine)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func expandPorts(portsStr string, needsWildcard bool) (*[]tailcfg.PortRange, error) {
|
||||||
|
if portsStr == "*" {
|
||||||
|
return &[]tailcfg.PortRange{
|
||||||
|
{First: portRangeBegin, Last: portRangeEnd},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if needsWildcard {
|
||||||
|
return nil, errWildcardIsNeeded
|
||||||
}
|
}
|
||||||
|
|
||||||
ports := []tailcfg.PortRange{}
|
ports := []tailcfg.PortRange{}
|
||||||
for _, p := range strings.Split(s, ",") {
|
for _, portStr := range strings.Split(portsStr, ",") {
|
||||||
rang := strings.Split(p, "-")
|
rang := strings.Split(portStr, "-")
|
||||||
if len(rang) == 1 {
|
switch len(rang) {
|
||||||
pi, err := strconv.ParseUint(rang[0], 10, 16)
|
case 1:
|
||||||
|
port, err := strconv.ParseUint(rang[0], Base10, BitSize16)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
ports = append(ports, tailcfg.PortRange{
|
ports = append(ports, tailcfg.PortRange{
|
||||||
First: uint16(pi),
|
First: uint16(port),
|
||||||
Last: uint16(pi),
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
last, err := strconv.ParseUint(rang[1], 10, 16)
|
last, err := strconv.ParseUint(rang[1], Base10, BitSize16)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -258,9 +691,90 @@ func (h *Headscale) expandPorts(s string) (*[]tailcfg.PortRange, error) {
|
|||||||
First: uint16(start),
|
First: uint16(start),
|
||||||
Last: uint16(last),
|
Last: uint16(last),
|
||||||
})
|
})
|
||||||
} else {
|
|
||||||
return nil, errorInvalidPortFormat
|
default:
|
||||||
|
return nil, errInvalidPortFormat
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return &ports, nil
|
return &ports, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func filterMachinesByUser(machines []Machine, user string) []Machine {
|
||||||
|
out := []Machine{}
|
||||||
|
for _, machine := range machines {
|
||||||
|
if machine.User.Name == user {
|
||||||
|
out = append(out, machine)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// expandTagOwners will return a list of user. An owner can be either a user 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 user 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
|
||||||
|
}
|
||||||
|
|||||||
1695
acls_test.go
1695
acls_test.go
File diff suppressed because it is too large
Load Diff
141
acls_types.go
141
acls_types.go
@@ -1,70 +1,145 @@
|
|||||||
package headscale
|
package headscale
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/netip"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/tailscale/hujson"
|
"github.com/tailscale/hujson"
|
||||||
"inet.af/netaddr"
|
"gopkg.in/yaml.v3"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ACLPolicy represents a Tailscale ACL Policy
|
// ACLPolicy represents a Tailscale ACL Policy.
|
||||||
type ACLPolicy struct {
|
type ACLPolicy struct {
|
||||||
Groups Groups `json:"Groups"`
|
Groups Groups `json:"groups" yaml:"groups"`
|
||||||
Hosts Hosts `json:"Hosts"`
|
Hosts Hosts `json:"hosts" yaml:"hosts"`
|
||||||
TagOwners TagOwners `json:"TagOwners"`
|
TagOwners TagOwners `json:"tagOwners" yaml:"tagOwners"`
|
||||||
ACLs []ACL `json:"ACLs"`
|
ACLs []ACL `json:"acls" yaml:"acls"`
|
||||||
Tests []ACLTest `json:"Tests"`
|
Tests []ACLTest `json:"tests" yaml:"tests"`
|
||||||
|
AutoApprovers AutoApprovers `json:"autoApprovers" yaml:"autoApprovers"`
|
||||||
|
SSHs []SSH `json:"ssh" yaml:"ssh"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ACL is a basic rule for the ACL Policy
|
// ACL is a basic rule for the ACL Policy.
|
||||||
type ACL struct {
|
type ACL struct {
|
||||||
Action string `json:"Action"`
|
Action string `json:"action" yaml:"action"`
|
||||||
Users []string `json:"Users"`
|
Protocol string `json:"proto" yaml:"proto"`
|
||||||
Ports []string `json:"Ports"`
|
Sources []string `json:"src" yaml:"src"`
|
||||||
|
Destinations []string `json:"dst" yaml:"dst"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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
|
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
|
type Hosts map[string]netip.Prefix
|
||||||
|
|
||||||
// TagOwners specify what users (namespaces?) are allow to use certain tags
|
// TagOwners specify what users (users?) are allow to use certain tags.
|
||||||
type TagOwners map[string][]string
|
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 {
|
type ACLTest struct {
|
||||||
User string `json:"User"`
|
Source string `json:"src" yaml:"src"`
|
||||||
Allow []string `json:"Allow"`
|
Accept []string `json:"accept" yaml:"accept"`
|
||||||
Deny []string `json:"Deny,omitempty"`
|
Deny []string `json:"deny,omitempty" yaml:"deny,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// UnmarshalJSON allows to parse the Hosts directly into netaddr objects
|
// AutoApprovers specify which users (users?), groups or tags have their advertised routes
|
||||||
func (h *Hosts) UnmarshalJSON(data []byte) error {
|
// or exit node status automatically enabled.
|
||||||
hosts := Hosts{}
|
type AutoApprovers struct {
|
||||||
hs := make(map[string]string)
|
Routes map[string][]string `json:"routes" yaml:"routes"`
|
||||||
err := hujson.Unmarshal(data, &hs)
|
ExitNode []string `json:"exitNode" yaml:"exitNode"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SSH controls who can ssh into which machines.
|
||||||
|
type SSH struct {
|
||||||
|
Action string `json:"action" yaml:"action"`
|
||||||
|
Sources []string `json:"src" yaml:"src"`
|
||||||
|
Destinations []string `json:"dst" yaml:"dst"`
|
||||||
|
Users []string `json:"users" yaml:"users"`
|
||||||
|
CheckPeriod string `json:"checkPeriod,omitempty" yaml:"checkPeriod,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnmarshalJSON allows to parse the Hosts directly into netip objects.
|
||||||
|
func (hosts *Hosts) UnmarshalJSON(data []byte) error {
|
||||||
|
newHosts := Hosts{}
|
||||||
|
hostIPPrefixMap := make(map[string]string)
|
||||||
|
ast, err := hujson.Parse(data)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
for k, v := range hs {
|
ast.Standardize()
|
||||||
if !strings.Contains(v, "/") {
|
data = ast.Pack()
|
||||||
v = v + "/32"
|
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 := netip.ParsePrefix(prefixStr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
hosts[k] = prefix
|
newHosts[host] = prefix
|
||||||
}
|
}
|
||||||
*h = hosts
|
*hosts = newHosts
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsZero is perhaps a bit naive here
|
// UnmarshalYAML allows to parse the Hosts directly into netip objects.
|
||||||
func (p ACLPolicy) IsZero() bool {
|
func (hosts *Hosts) UnmarshalYAML(data []byte) error {
|
||||||
if len(p.Groups) == 0 && len(p.Hosts) == 0 && len(p.ACLs) == 0 {
|
newHosts := Hosts{}
|
||||||
|
hostIPPrefixMap := make(map[string]string)
|
||||||
|
|
||||||
|
err := yaml.Unmarshal(data, &hostIPPrefixMap)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for host, prefixStr := range hostIPPrefixMap {
|
||||||
|
prefix, err := netip.ParsePrefix(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 true
|
||||||
}
|
}
|
||||||
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Returns the list of autoApproving users, groups or tags for a given IPPrefix.
|
||||||
|
func (autoApprovers *AutoApprovers) GetRouteApprovers(
|
||||||
|
prefix netip.Prefix,
|
||||||
|
) ([]string, error) {
|
||||||
|
if prefix.Bits() == 0 {
|
||||||
|
return autoApprovers.ExitNode, nil // 0.0.0.0/0, ::/0 or equivalent
|
||||||
|
}
|
||||||
|
|
||||||
|
approverAliases := []string{}
|
||||||
|
|
||||||
|
for autoApprovedPrefix, autoApproverAliases := range autoApprovers.Routes {
|
||||||
|
autoApprovedPrefix, err := netip.ParsePrefix(autoApprovedPrefix)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if prefix.Bits() >= autoApprovedPrefix.Bits() &&
|
||||||
|
autoApprovedPrefix.Contains(prefix.Masked().Addr()) {
|
||||||
|
approverAliases = append(approverAliases, autoApproverAliases...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return approverAliases, nil
|
||||||
|
}
|
||||||
|
|||||||
500
api.go
500
api.go
@@ -1,416 +1,168 @@
|
|||||||
package headscale
|
package headscale
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/binary"
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"html/template"
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/gorilla/mux"
|
||||||
"github.com/rs/zerolog/log"
|
"github.com/rs/zerolog/log"
|
||||||
|
"tailscale.com/types/key"
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
"github.com/klauspost/compress/zstd"
|
|
||||||
"gorm.io/gorm"
|
|
||||||
"tailscale.com/tailcfg"
|
|
||||||
"tailscale.com/types/wgkey"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// KeyHandler provides the Headscale pub key
|
const (
|
||||||
// Listens in /key
|
// TODO(juan): remove this once https://github.com/juanfont/headscale/issues/727 is fixed.
|
||||||
func (h *Headscale) KeyHandler(c *gin.Context) {
|
registrationHoldoff = time.Second * 5
|
||||||
c.Data(200, "text/plain; charset=utf-8", []byte(h.publicKey.HexString()))
|
reservedResponseHeaderSize = 4
|
||||||
|
RegisterMethodAuthKey = "authkey"
|
||||||
|
RegisterMethodOIDC = "oidc"
|
||||||
|
RegisterMethodCLI = "cli"
|
||||||
|
ErrRegisterMethodCLIDoesNotSupportExpire = Error(
|
||||||
|
"machines registered with CLI does not support expire",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
func (h *Headscale) HealthHandler(
|
||||||
|
writer http.ResponseWriter,
|
||||||
|
req *http.Request,
|
||||||
|
) {
|
||||||
|
respond := func(err error) {
|
||||||
|
writer.Header().Set("Content-Type", "application/health+json; charset=utf-8")
|
||||||
|
|
||||||
|
res := struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
}{
|
||||||
|
Status: "pass",
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
writer.WriteHeader(http.StatusInternalServerError)
|
||||||
|
log.Error().Caller().Err(err).Msg("health check failed")
|
||||||
|
res.Status = "fail"
|
||||||
|
}
|
||||||
|
|
||||||
|
buf, err := json.Marshal(res)
|
||||||
|
if err != nil {
|
||||||
|
log.Error().Caller().Err(err).Msg("marshal failed")
|
||||||
|
}
|
||||||
|
_, err = writer.Write(buf)
|
||||||
|
if err != nil {
|
||||||
|
log.Error().Caller().Err(err).Msg("write failed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.pingDB(req.Context()); err != nil {
|
||||||
|
respond(err)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
respond(nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type registerWebAPITemplateConfig struct {
|
||||||
|
Key string
|
||||||
|
}
|
||||||
|
|
||||||
|
var registerWebAPITemplate = template.Must(
|
||||||
|
template.New("registerweb").Parse(`
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>Registration - Headscale</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>headscale</h1>
|
||||||
|
<h2>Machine registration</h2>
|
||||||
|
<p>
|
||||||
|
Run the command below in the headscale server to add this machine to your network:
|
||||||
|
</p>
|
||||||
|
<pre><code>headscale nodes register --user USERNAME --key {{.Key}}</code></pre>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`))
|
||||||
|
|
||||||
// RegisterWebAPI shows a simple message in the browser to point to the CLI
|
// RegisterWebAPI shows a simple message in the browser to point to the CLI
|
||||||
// Listens in /register
|
// Listens in /register/:nkey.
|
||||||
func (h *Headscale) RegisterWebAPI(c *gin.Context) {
|
//
|
||||||
mKeyStr := c.Query("key")
|
// This is not part of the Tailscale control API, as we could send whatever URL
|
||||||
if mKeyStr == "" {
|
// in the RegisterResponse.AuthURL field.
|
||||||
c.String(http.StatusBadRequest, "Wrong params")
|
func (h *Headscale) RegisterWebAPI(
|
||||||
return
|
writer http.ResponseWriter,
|
||||||
}
|
req *http.Request,
|
||||||
|
) {
|
||||||
|
vars := mux.Vars(req)
|
||||||
|
nodeKeyStr, ok := vars["nkey"]
|
||||||
|
|
||||||
c.Data(http.StatusOK, "text/html; charset=utf-8", []byte(fmt.Sprintf(`
|
if !NodePublicKeyRegex.Match([]byte(nodeKeyStr)) {
|
||||||
<html>
|
log.Warn().Str("node_key", nodeKeyStr).Msg("Invalid node key passed to registration url")
|
||||||
<body>
|
|
||||||
<h1>headscale</h1>
|
|
||||||
<p>
|
|
||||||
Run the command below in the headscale server to add this machine to your network:
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p>
|
writer.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||||
<code>
|
writer.WriteHeader(http.StatusUnauthorized)
|
||||||
<b>headscale -n NAMESPACE nodes register %s</b>
|
_, err := writer.Write([]byte("Unauthorized"))
|
||||||
</code>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
|
|
||||||
`, mKeyStr)))
|
|
||||||
}
|
|
||||||
|
|
||||||
// RegistrationHandler handles the actual registration process of a machine
|
|
||||||
// Endpoint /machine/:id
|
|
||||||
func (h *Headscale) RegistrationHandler(c *gin.Context) {
|
|
||||||
body, _ := io.ReadAll(c.Request.Body)
|
|
||||||
mKeyStr := c.Param("id")
|
|
||||||
mKey, err := wgkey.ParseHex(mKeyStr)
|
|
||||||
if err != nil {
|
|
||||||
log.Error().
|
|
||||||
Str("handler", "Registration").
|
|
||||||
Err(err).
|
|
||||||
Msg("Cannot parse machine key")
|
|
||||||
machineRegistrations.WithLabelValues("unkown", "web", "error", "unknown").Inc()
|
|
||||||
c.String(http.StatusInternalServerError, "Sad!")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
req := tailcfg.RegisterRequest{}
|
|
||||||
err = decode(body, &req, &mKey, h.privateKey)
|
|
||||||
if err != nil {
|
|
||||||
log.Error().
|
|
||||||
Str("handler", "Registration").
|
|
||||||
Err(err).
|
|
||||||
Msg("Cannot decode message")
|
|
||||||
machineRegistrations.WithLabelValues("unkown", "web", "error", "unknown").Inc()
|
|
||||||
c.String(http.StatusInternalServerError, "Very sad!")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
now := time.Now().UTC()
|
|
||||||
var m Machine
|
|
||||||
if result := h.db.Preload("Namespace").First(&m, "machine_key = ?", mKey.HexString()); errors.Is(result.Error, gorm.ErrRecordNotFound) {
|
|
||||||
log.Info().Str("machine", req.Hostinfo.Hostname).Msg("New machine")
|
|
||||||
m = Machine{
|
|
||||||
Expiry: &req.Expiry,
|
|
||||||
MachineKey: mKey.HexString(),
|
|
||||||
Name: req.Hostinfo.Hostname,
|
|
||||||
NodeKey: wgkey.Key(req.NodeKey).HexString(),
|
|
||||||
LastSuccessfulUpdate: &now,
|
|
||||||
}
|
|
||||||
if err := h.db.Create(&m).Error; err != nil {
|
|
||||||
log.Error().
|
|
||||||
Str("handler", "Registration").
|
|
||||||
Err(err).
|
|
||||||
Msg("Could not create row")
|
|
||||||
machineRegistrations.WithLabelValues("unkown", "web", "error", m.Namespace.Name).Inc()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if !m.Registered && req.Auth.AuthKey != "" {
|
|
||||||
h.handleAuthKey(c, h.db, mKey, req, m)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
resp := tailcfg.RegisterResponse{}
|
|
||||||
|
|
||||||
// We have the updated key!
|
|
||||||
if m.NodeKey == wgkey.Key(req.NodeKey).HexString() {
|
|
||||||
if m.Registered {
|
|
||||||
log.Debug().
|
|
||||||
Str("handler", "Registration").
|
|
||||||
Str("machine", m.Name).
|
|
||||||
Msg("Client is registered and we have the current NodeKey. All clear to /map")
|
|
||||||
|
|
||||||
resp.AuthURL = ""
|
|
||||||
resp.MachineAuthorized = true
|
|
||||||
resp.User = *m.Namespace.toUser()
|
|
||||||
respBody, err := encode(resp, &mKey, h.privateKey)
|
|
||||||
if err != nil {
|
|
||||||
log.Error().
|
|
||||||
Str("handler", "Registration").
|
|
||||||
Err(err).
|
|
||||||
Msg("Cannot encode message")
|
|
||||||
machineRegistrations.WithLabelValues("update", "web", "error", m.Namespace.Name).Inc()
|
|
||||||
c.String(http.StatusInternalServerError, "")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
machineRegistrations.WithLabelValues("update", "web", "success", m.Namespace.Name).Inc()
|
|
||||||
c.Data(200, "application/json; charset=utf-8", respBody)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Debug().
|
|
||||||
Str("handler", "Registration").
|
|
||||||
Str("machine", m.Name).
|
|
||||||
Msg("Not registered and not NodeKey rotation. Sending a authurl to register")
|
|
||||||
resp.AuthURL = fmt.Sprintf("%s/register?key=%s",
|
|
||||||
h.cfg.ServerURL, mKey.HexString())
|
|
||||||
respBody, err := encode(resp, &mKey, h.privateKey)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error().
|
log.Error().
|
||||||
Str("handler", "Registration").
|
Caller().
|
||||||
Err(err).
|
Err(err).
|
||||||
Msg("Cannot encode message")
|
Msg("Failed to write response")
|
||||||
machineRegistrations.WithLabelValues("new", "web", "error", m.Namespace.Name).Inc()
|
|
||||||
c.String(http.StatusInternalServerError, "")
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
machineRegistrations.WithLabelValues("new", "web", "success", m.Namespace.Name).Inc()
|
|
||||||
c.Data(200, "application/json; charset=utf-8", respBody)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// The NodeKey we have matches OldNodeKey, which means this is a refresh after an key expiration
|
// We need to make sure we dont open for XSS style injections, if the parameter that
|
||||||
if m.NodeKey == wgkey.Key(req.OldNodeKey).HexString() {
|
// is passed as a key is not parsable/validated as a NodePublic key, then fail to render
|
||||||
log.Debug().
|
// the template and log an error.
|
||||||
Str("handler", "Registration").
|
var nodeKey key.NodePublic
|
||||||
Str("machine", m.Name).
|
err := nodeKey.UnmarshalText(
|
||||||
Msg("We have the OldNodeKey in the database. This is a key refresh")
|
[]byte(NodePublicKeyEnsurePrefix(nodeKeyStr)),
|
||||||
m.NodeKey = wgkey.Key(req.NodeKey).HexString()
|
)
|
||||||
h.db.Save(&m)
|
|
||||||
|
|
||||||
resp.AuthURL = ""
|
if !ok || nodeKeyStr == "" || err != nil {
|
||||||
resp.User = *m.Namespace.toUser()
|
log.Warn().Err(err).Msg("Failed to parse incoming nodekey")
|
||||||
respBody, err := encode(resp, &mKey, h.privateKey)
|
|
||||||
|
writer.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||||
|
writer.WriteHeader(http.StatusBadRequest)
|
||||||
|
_, err := writer.Write([]byte("Wrong params"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error().
|
log.Error().
|
||||||
Str("handler", "Registration").
|
Caller().
|
||||||
Err(err).
|
Err(err).
|
||||||
Msg("Cannot encode message")
|
Msg("Failed to write response")
|
||||||
c.String(http.StatusInternalServerError, "Extremely sad!")
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
c.Data(200, "application/json; charset=utf-8", respBody)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// We arrive here after a client is restarted without finalizing the authentication flow or
|
var content bytes.Buffer
|
||||||
// when headscale is stopped in the middle of the auth process.
|
if err := registerWebAPITemplate.Execute(&content, registerWebAPITemplateConfig{
|
||||||
if m.Registered {
|
Key: nodeKeyStr,
|
||||||
log.Debug().
|
}); err != nil {
|
||||||
Str("handler", "Registration").
|
log.Error().
|
||||||
Str("machine", m.Name).
|
Str("func", "RegisterWebAPI").
|
||||||
Msg("The node is sending us a new NodeKey, but machine is registered. All clear for /map")
|
Err(err).
|
||||||
resp.AuthURL = ""
|
Msg("Could not render register web API template")
|
||||||
resp.MachineAuthorized = true
|
writer.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||||
resp.User = *m.Namespace.toUser()
|
writer.WriteHeader(http.StatusInternalServerError)
|
||||||
respBody, err := encode(resp, &mKey, h.privateKey)
|
_, err = writer.Write([]byte("Could not render register web API template"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error().
|
log.Error().
|
||||||
Str("handler", "Registration").
|
Caller().
|
||||||
Err(err).
|
Err(err).
|
||||||
Msg("Cannot encode message")
|
Msg("Failed to write response")
|
||||||
c.String(http.StatusInternalServerError, "")
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
c.Data(200, "application/json; charset=utf-8", respBody)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Debug().
|
writer.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
Str("handler", "Registration").
|
writer.WriteHeader(http.StatusOK)
|
||||||
Str("machine", m.Name).
|
_, err = writer.Write(content.Bytes())
|
||||||
Msg("The node is sending us a new NodeKey, sending auth url")
|
|
||||||
resp.AuthURL = fmt.Sprintf("%s/register?key=%s",
|
|
||||||
h.cfg.ServerURL, mKey.HexString())
|
|
||||||
respBody, err := encode(resp, &mKey, h.privateKey)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error().
|
log.Error().
|
||||||
Str("handler", "Registration").
|
Caller().
|
||||||
Err(err).
|
Err(err).
|
||||||
Msg("Cannot encode message")
|
Msg("Failed to write response")
|
||||||
c.String(http.StatusInternalServerError, "")
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
c.Data(200, "application/json; charset=utf-8", respBody)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *Headscale) getMapResponse(mKey wgkey.Key, req tailcfg.MapRequest, m *Machine) ([]byte, error) {
|
|
||||||
log.Trace().
|
|
||||||
Str("func", "getMapResponse").
|
|
||||||
Str("machine", req.Hostinfo.Hostname).
|
|
||||||
Msg("Creating Map response")
|
|
||||||
node, err := m.toNode(h.cfg.BaseDomain, h.cfg.DNSConfig, true)
|
|
||||||
if err != nil {
|
|
||||||
log.Error().
|
|
||||||
Str("func", "getMapResponse").
|
|
||||||
Err(err).
|
|
||||||
Msg("Cannot convert to node")
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
peers, err := h.getPeers(m)
|
|
||||||
if err != nil {
|
|
||||||
log.Error().
|
|
||||||
Str("func", "getMapResponse").
|
|
||||||
Err(err).
|
|
||||||
Msg("Cannot fetch peers")
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
profiles := getMapResponseUserProfiles(*m, peers)
|
|
||||||
|
|
||||||
nodePeers, err := peers.toNodes(h.cfg.BaseDomain, h.cfg.DNSConfig, true)
|
|
||||||
if err != nil {
|
|
||||||
log.Error().
|
|
||||||
Str("func", "getMapResponse").
|
|
||||||
Err(err).
|
|
||||||
Msg("Failed to convert peers to Tailscale nodes")
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
dnsConfig, err := getMapResponseDNSConfig(h.cfg.DNSConfig, h.cfg.BaseDomain, *m, peers)
|
|
||||||
if err != nil {
|
|
||||||
log.Error().
|
|
||||||
Str("func", "getMapResponse").
|
|
||||||
Err(err).
|
|
||||||
Msg("Failed generate the DNSConfig")
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
resp := tailcfg.MapResponse{
|
|
||||||
KeepAlive: false,
|
|
||||||
Node: node,
|
|
||||||
Peers: nodePeers,
|
|
||||||
DNSConfig: dnsConfig,
|
|
||||||
Domain: h.cfg.BaseDomain,
|
|
||||||
PacketFilter: *h.aclRules,
|
|
||||||
DERPMap: h.cfg.DerpMap,
|
|
||||||
UserProfiles: profiles,
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Trace().
|
|
||||||
Str("func", "getMapResponse").
|
|
||||||
Str("machine", req.Hostinfo.Hostname).
|
|
||||||
// Interface("payload", resp).
|
|
||||||
Msgf("Generated map response: %s", tailMapResponseToString(resp))
|
|
||||||
|
|
||||||
var respBody []byte
|
|
||||||
if req.Compress == "zstd" {
|
|
||||||
src, _ := json.Marshal(resp)
|
|
||||||
|
|
||||||
encoder, _ := zstd.NewWriter(nil)
|
|
||||||
srcCompressed := encoder.EncodeAll(src, nil)
|
|
||||||
respBody, err = encodeMsg(srcCompressed, &mKey, h.privateKey)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
respBody, err = encode(resp, &mKey, h.privateKey)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// declare the incoming size on the first 4 bytes
|
|
||||||
data := make([]byte, 4)
|
|
||||||
binary.LittleEndian.PutUint32(data, uint32(len(respBody)))
|
|
||||||
data = append(data, respBody...)
|
|
||||||
return data, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *Headscale) getMapKeepAliveResponse(mKey wgkey.Key, req tailcfg.MapRequest, m *Machine) ([]byte, error) {
|
|
||||||
resp := tailcfg.MapResponse{
|
|
||||||
KeepAlive: true,
|
|
||||||
}
|
|
||||||
var respBody []byte
|
|
||||||
var err error
|
|
||||||
if req.Compress == "zstd" {
|
|
||||||
src, _ := json.Marshal(resp)
|
|
||||||
encoder, _ := zstd.NewWriter(nil)
|
|
||||||
srcCompressed := encoder.EncodeAll(src, nil)
|
|
||||||
respBody, err = encodeMsg(srcCompressed, &mKey, h.privateKey)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
respBody, err = encode(resp, &mKey, h.privateKey)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
data := make([]byte, 4)
|
|
||||||
binary.LittleEndian.PutUint32(data, uint32(len(respBody)))
|
|
||||||
data = append(data, respBody...)
|
|
||||||
return data, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *Headscale) handleAuthKey(c *gin.Context, db *gorm.DB, idKey wgkey.Key, req tailcfg.RegisterRequest, m Machine) {
|
|
||||||
log.Debug().
|
|
||||||
Str("func", "handleAuthKey").
|
|
||||||
Str("machine", req.Hostinfo.Hostname).
|
|
||||||
Msgf("Processing auth key for %s", req.Hostinfo.Hostname)
|
|
||||||
resp := tailcfg.RegisterResponse{}
|
|
||||||
pak, err := h.checkKeyValidity(req.Auth.AuthKey)
|
|
||||||
if err != nil {
|
|
||||||
log.Error().
|
|
||||||
Str("func", "handleAuthKey").
|
|
||||||
Str("machine", m.Name).
|
|
||||||
Err(err).
|
|
||||||
Msg("Failed authentication via AuthKey")
|
|
||||||
resp.MachineAuthorized = false
|
|
||||||
respBody, err := encode(resp, &idKey, h.privateKey)
|
|
||||||
if err != nil {
|
|
||||||
log.Error().
|
|
||||||
Str("func", "handleAuthKey").
|
|
||||||
Str("machine", m.Name).
|
|
||||||
Err(err).
|
|
||||||
Msg("Cannot encode message")
|
|
||||||
c.String(http.StatusInternalServerError, "")
|
|
||||||
machineRegistrations.WithLabelValues("new", "authkey", "error", m.Namespace.Name).Inc()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c.Data(401, "application/json; charset=utf-8", respBody)
|
|
||||||
log.Error().
|
|
||||||
Str("func", "handleAuthKey").
|
|
||||||
Str("machine", m.Name).
|
|
||||||
Msg("Failed authentication via AuthKey")
|
|
||||||
machineRegistrations.WithLabelValues("new", "authkey", "error", m.Namespace.Name).Inc()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Debug().
|
|
||||||
Str("func", "handleAuthKey").
|
|
||||||
Str("machine", m.Name).
|
|
||||||
Msg("Authentication key was valid, proceeding to acquire an IP address")
|
|
||||||
ip, err := h.getAvailableIP()
|
|
||||||
if err != nil {
|
|
||||||
log.Error().
|
|
||||||
Str("func", "handleAuthKey").
|
|
||||||
Str("machine", m.Name).
|
|
||||||
Msg("Failed to find an available IP")
|
|
||||||
machineRegistrations.WithLabelValues("new", "authkey", "error", m.Namespace.Name).Inc()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
log.Info().
|
|
||||||
Str("func", "handleAuthKey").
|
|
||||||
Str("machine", m.Name).
|
|
||||||
Str("ip", ip.String()).
|
|
||||||
Msgf("Assigning %s to %s", ip, m.Name)
|
|
||||||
|
|
||||||
m.AuthKeyID = uint(pak.ID)
|
|
||||||
m.IPAddress = ip.String()
|
|
||||||
m.NamespaceID = pak.NamespaceID
|
|
||||||
m.NodeKey = wgkey.Key(req.NodeKey).HexString() // we update it just in case
|
|
||||||
m.Registered = true
|
|
||||||
m.RegisterMethod = "authKey"
|
|
||||||
db.Save(&m)
|
|
||||||
|
|
||||||
pak.Used = true
|
|
||||||
db.Save(&pak)
|
|
||||||
|
|
||||||
resp.MachineAuthorized = true
|
|
||||||
resp.User = *pak.Namespace.toUser()
|
|
||||||
respBody, err := encode(resp, &idKey, h.privateKey)
|
|
||||||
if err != nil {
|
|
||||||
log.Error().
|
|
||||||
Str("func", "handleAuthKey").
|
|
||||||
Str("machine", m.Name).
|
|
||||||
Err(err).
|
|
||||||
Msg("Cannot encode message")
|
|
||||||
machineRegistrations.WithLabelValues("new", "authkey", "error", m.Namespace.Name).Inc()
|
|
||||||
c.String(http.StatusInternalServerError, "Extremely sad!")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
machineRegistrations.WithLabelValues("new", "authkey", "success", m.Namespace.Name).Inc()
|
|
||||||
c.Data(200, "application/json; charset=utf-8", respBody)
|
|
||||||
log.Info().
|
|
||||||
Str("func", "handleAuthKey").
|
|
||||||
Str("machine", m.Name).
|
|
||||||
Str("ip", ip.String()).
|
|
||||||
Msg("Successfully authenticated via AuthKey")
|
|
||||||
}
|
}
|
||||||
|
|||||||
113
api_common.go
Normal file
113
api_common.go
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
package headscale
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/rs/zerolog/log"
|
||||||
|
"tailscale.com/tailcfg"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (h *Headscale) generateMapResponse(
|
||||||
|
mapRequest tailcfg.MapRequest,
|
||||||
|
machine *Machine,
|
||||||
|
) (*tailcfg.MapResponse, error) {
|
||||||
|
log.Trace().
|
||||||
|
Str("func", "generateMapResponse").
|
||||||
|
Str("machine", mapRequest.Hostinfo.Hostname).
|
||||||
|
Msg("Creating Map response")
|
||||||
|
node, err := h.toNode(*machine, h.cfg.BaseDomain, h.cfg.DNSConfig)
|
||||||
|
if err != nil {
|
||||||
|
log.Error().
|
||||||
|
Caller().
|
||||||
|
Str("func", "generateMapResponse").
|
||||||
|
Err(err).
|
||||||
|
Msg("Cannot convert to node")
|
||||||
|
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
peers, err := h.getValidPeers(machine)
|
||||||
|
if err != nil {
|
||||||
|
log.Error().
|
||||||
|
Caller().
|
||||||
|
Str("func", "generateMapResponse").
|
||||||
|
Err(err).
|
||||||
|
Msg("Cannot fetch peers")
|
||||||
|
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
profiles := h.getMapResponseUserProfiles(*machine, peers)
|
||||||
|
|
||||||
|
nodePeers, err := h.toNodes(peers, h.cfg.BaseDomain, h.cfg.DNSConfig)
|
||||||
|
if err != nil {
|
||||||
|
log.Error().
|
||||||
|
Caller().
|
||||||
|
Str("func", "generateMapResponse").
|
||||||
|
Err(err).
|
||||||
|
Msg("Failed to convert peers to Tailscale nodes")
|
||||||
|
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
dnsConfig := getMapResponseDNSConfig(
|
||||||
|
h.cfg.DNSConfig,
|
||||||
|
h.cfg.BaseDomain,
|
||||||
|
*machine,
|
||||||
|
peers,
|
||||||
|
)
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
|
resp := tailcfg.MapResponse{
|
||||||
|
KeepAlive: false,
|
||||||
|
Node: node,
|
||||||
|
|
||||||
|
// TODO: Only send if updated
|
||||||
|
DERPMap: h.DERPMap,
|
||||||
|
|
||||||
|
// TODO: Only send if updated
|
||||||
|
Peers: nodePeers,
|
||||||
|
|
||||||
|
// TODO(kradalby): Implement:
|
||||||
|
// https://github.com/tailscale/tailscale/blob/main/tailcfg/tailcfg.go#L1351-L1374
|
||||||
|
// PeersChanged
|
||||||
|
// PeersRemoved
|
||||||
|
// PeersChangedPatch
|
||||||
|
// PeerSeenChange
|
||||||
|
// OnlineChange
|
||||||
|
|
||||||
|
// TODO: Only send if updated
|
||||||
|
DNSConfig: dnsConfig,
|
||||||
|
|
||||||
|
// TODO: Only send if updated
|
||||||
|
Domain: h.cfg.BaseDomain,
|
||||||
|
|
||||||
|
// Do not instruct clients to collect services, we do not
|
||||||
|
// support or do anything with them
|
||||||
|
CollectServices: "false",
|
||||||
|
|
||||||
|
// TODO: Only send if updated
|
||||||
|
PacketFilter: h.aclRules,
|
||||||
|
|
||||||
|
UserProfiles: profiles,
|
||||||
|
|
||||||
|
// TODO: Only send if updated
|
||||||
|
SSHPolicy: h.sshPolicy,
|
||||||
|
|
||||||
|
ControlTime: &now,
|
||||||
|
|
||||||
|
Debug: &tailcfg.Debug{
|
||||||
|
DisableLogTail: !h.cfg.LogTail.Enabled,
|
||||||
|
RandomizeClientPort: h.cfg.RandomizeClientPort,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Trace().
|
||||||
|
Str("func", "generateMapResponse").
|
||||||
|
Str("machine", mapRequest.Hostinfo.Hostname).
|
||||||
|
// Interface("payload", resp).
|
||||||
|
Msgf("Generated map response: %s", tailMapResponseToString(resp))
|
||||||
|
|
||||||
|
return &resp, nil
|
||||||
|
}
|
||||||
157
api_key.go
Normal file
157
api_key.go
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
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
|
||||||
|
|
||||||
|
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 user, 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,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.db.Save(&key).Error; err != nil {
|
||||||
|
return "", nil, fmt.Errorf("failed to save API key to database: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return keyStr, &key, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListAPIKeys returns the list of ApiKeys for a user.
|
||||||
|
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, found := strings.Cut(keyStr, ".")
|
||||||
|
if !found {
|
||||||
|
return false, ErrAPIKeyFailedToParse
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (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)
|
||||||
|
}
|
||||||
25
app_test.go
25
app_test.go
@@ -1,12 +1,11 @@
|
|||||||
package headscale
|
package headscale
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"io/ioutil"
|
"net/netip"
|
||||||
"os"
|
"os"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gopkg.in/check.v1"
|
"gopkg.in/check.v1"
|
||||||
"inet.af/netaddr"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func Test(t *testing.T) {
|
func Test(t *testing.T) {
|
||||||
@@ -17,8 +16,10 @@ var _ = check.Suite(&Suite{})
|
|||||||
|
|
||||||
type Suite struct{}
|
type Suite struct{}
|
||||||
|
|
||||||
var tmpDir string
|
var (
|
||||||
var h Headscale
|
tmpDir string
|
||||||
|
app Headscale
|
||||||
|
)
|
||||||
|
|
||||||
func (s *Suite) SetUpTest(c *check.C) {
|
func (s *Suite) SetUpTest(c *check.C) {
|
||||||
s.ResetDB(c)
|
s.ResetDB(c)
|
||||||
@@ -33,26 +34,28 @@ func (s *Suite) ResetDB(c *check.C) {
|
|||||||
os.RemoveAll(tmpDir)
|
os.RemoveAll(tmpDir)
|
||||||
}
|
}
|
||||||
var err error
|
var err error
|
||||||
tmpDir, err = ioutil.TempDir("", "autoygg-client-test")
|
tmpDir, err = os.MkdirTemp("", "autoygg-client-test")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.Fatal(err)
|
c.Fatal(err)
|
||||||
}
|
}
|
||||||
cfg := Config{
|
cfg := Config{
|
||||||
IPPrefix: netaddr.MustParseIPPrefix("10.27.0.0/23"),
|
IPPrefixes: []netip.Prefix{
|
||||||
|
netip.MustParsePrefix("10.27.0.0/23"),
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
h = Headscale{
|
app = Headscale{
|
||||||
cfg: cfg,
|
cfg: &cfg,
|
||||||
dbType: "sqlite3",
|
dbType: "sqlite3",
|
||||||
dbString: tmpDir + "/headscale_test.db",
|
dbString: tmpDir + "/headscale_test.db",
|
||||||
}
|
}
|
||||||
err = h.initDB()
|
err = app.initDB()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.Fatal(err)
|
c.Fatal(err)
|
||||||
}
|
}
|
||||||
db, err := h.openDB()
|
db, err := app.openDB()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.Fatal(err)
|
c.Fatal(err)
|
||||||
}
|
}
|
||||||
h.db = db
|
app.db = db
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,226 +0,0 @@
|
|||||||
package headscale
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"net/http"
|
|
||||||
"text/template"
|
|
||||||
|
|
||||||
"github.com/rs/zerolog/log"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
"github.com/gofrs/uuid"
|
|
||||||
)
|
|
||||||
|
|
||||||
// AppleMobileConfig shows a simple message in the browser to point to the CLI
|
|
||||||
// Listens in /register
|
|
||||||
func (h *Headscale) AppleMobileConfig(c *gin.Context) {
|
|
||||||
t := template.Must(template.New("apple").Parse(`
|
|
||||||
<html>
|
|
||||||
<body>
|
|
||||||
<h1>Apple configuration profiles</h1>
|
|
||||||
<p>
|
|
||||||
This page provides <a href="https://support.apple.com/guide/mdm/mdm-overview-mdmbf9e668/web">configuration profiles</a> for the official Tailscale clients for <a href="https://apps.apple.com/us/app/tailscale/id1470499037?ls=1">iOS</a> and <a href="https://apps.apple.com/ca/app/tailscale/id1475387142?mt=12">macOS</a>.
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
The profiles will configure Tailscale.app to use {{.Url}} as its control server.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<h3>Caution</h3>
|
|
||||||
<p>You should always inspect the profile before installing it:</p>
|
|
||||||
<!--
|
|
||||||
<p><code>curl {{.Url}}/apple/ios</code></p>
|
|
||||||
-->
|
|
||||||
<p><code>curl {{.Url}}/apple/macos</code></p>
|
|
||||||
|
|
||||||
<h2>Profiles</h2>
|
|
||||||
|
|
||||||
<!--
|
|
||||||
<h3>iOS</h3>
|
|
||||||
<p>
|
|
||||||
<a href="/apple/ios" download="headscale_ios.mobileconfig">iOS profile</a>
|
|
||||||
</p>
|
|
||||||
-->
|
|
||||||
|
|
||||||
<h3>macOS</h3>
|
|
||||||
<p>Headscale can be set to the default server by installing a Headscale configuration profile:</p>
|
|
||||||
<p>
|
|
||||||
<a href="/apple/macos" download="headscale_macos.mobileconfig">macOS profile</a>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<ol>
|
|
||||||
<li>Download the profile, then open it. When it has been opened, there should be a notification that a profile can be installed</li>
|
|
||||||
<li>Open System Preferences and go to "Profiles"</li>
|
|
||||||
<li>Find and install the Headscale profile</li>
|
|
||||||
<li>Restart Tailscale.app and log in</li>
|
|
||||||
</ol>
|
|
||||||
|
|
||||||
<p>Or</p>
|
|
||||||
<p>Use your terminal to configure the default setting for Tailscale by issuing:</p>
|
|
||||||
<code>defaults write io.tailscale.ipn.macos ControlURL {{.Url}}</code>
|
|
||||||
|
|
||||||
<p>Restart Tailscale.app and log in.</p>
|
|
||||||
|
|
||||||
</body>
|
|
||||||
</html>`))
|
|
||||||
|
|
||||||
config := map[string]interface{}{
|
|
||||||
"Url": h.cfg.ServerURL,
|
|
||||||
}
|
|
||||||
|
|
||||||
var payload bytes.Buffer
|
|
||||||
if err := t.Execute(&payload, config); err != nil {
|
|
||||||
log.Error().
|
|
||||||
Str("handler", "AppleMobileConfig").
|
|
||||||
Err(err).
|
|
||||||
Msg("Could not render Apple index template")
|
|
||||||
c.Data(http.StatusInternalServerError, "text/html; charset=utf-8", []byte("Could not render Apple index template"))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.Data(http.StatusOK, "text/html; charset=utf-8", payload.Bytes())
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *Headscale) ApplePlatformConfig(c *gin.Context) {
|
|
||||||
platform := c.Param("platform")
|
|
||||||
|
|
||||||
id, err := uuid.NewV4()
|
|
||||||
if err != nil {
|
|
||||||
log.Error().
|
|
||||||
Str("handler", "ApplePlatformConfig").
|
|
||||||
Err(err).
|
|
||||||
Msg("Failed not create UUID")
|
|
||||||
c.Data(http.StatusInternalServerError, "text/html; charset=utf-8", []byte("Failed to create UUID"))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
contentId, err := uuid.NewV4()
|
|
||||||
if err != nil {
|
|
||||||
log.Error().
|
|
||||||
Str("handler", "ApplePlatformConfig").
|
|
||||||
Err(err).
|
|
||||||
Msg("Failed not create UUID")
|
|
||||||
c.Data(http.StatusInternalServerError, "text/html; charset=utf-8", []byte("Failed to create UUID"))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
platformConfig := AppleMobilePlatformConfig{
|
|
||||||
UUID: contentId,
|
|
||||||
Url: h.cfg.ServerURL,
|
|
||||||
}
|
|
||||||
|
|
||||||
var payload bytes.Buffer
|
|
||||||
|
|
||||||
switch platform {
|
|
||||||
case "macos":
|
|
||||||
if err := macosTemplate.Execute(&payload, platformConfig); err != nil {
|
|
||||||
log.Error().
|
|
||||||
Str("handler", "ApplePlatformConfig").
|
|
||||||
Err(err).
|
|
||||||
Msg("Could not render Apple macOS template")
|
|
||||||
c.Data(http.StatusInternalServerError, "text/html; charset=utf-8", []byte("Could not render Apple macOS template"))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
case "ios":
|
|
||||||
if err := iosTemplate.Execute(&payload, platformConfig); err != nil {
|
|
||||||
log.Error().
|
|
||||||
Str("handler", "ApplePlatformConfig").
|
|
||||||
Err(err).
|
|
||||||
Msg("Could not render Apple iOS template")
|
|
||||||
c.Data(http.StatusInternalServerError, "text/html; charset=utf-8", []byte("Could not render Apple iOS template"))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
c.Data(http.StatusOK, "text/html; charset=utf-8", []byte("Invalid platform, only ios and macos is supported"))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
config := AppleMobileConfig{
|
|
||||||
UUID: id,
|
|
||||||
Url: h.cfg.ServerURL,
|
|
||||||
Payload: payload.String(),
|
|
||||||
}
|
|
||||||
|
|
||||||
var content bytes.Buffer
|
|
||||||
if err := commonTemplate.Execute(&content, config); err != nil {
|
|
||||||
log.Error().
|
|
||||||
Str("handler", "ApplePlatformConfig").
|
|
||||||
Err(err).
|
|
||||||
Msg("Could not render Apple platform template")
|
|
||||||
c.Data(http.StatusInternalServerError, "text/html; charset=utf-8", []byte("Could not render Apple platform template"))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.Data(http.StatusOK, "application/x-apple-aspen-config; charset=utf-8", content.Bytes())
|
|
||||||
}
|
|
||||||
|
|
||||||
type AppleMobileConfig struct {
|
|
||||||
UUID uuid.UUID
|
|
||||||
Url string
|
|
||||||
Payload string
|
|
||||||
}
|
|
||||||
|
|
||||||
type AppleMobilePlatformConfig struct {
|
|
||||||
UUID uuid.UUID
|
|
||||||
Url string
|
|
||||||
}
|
|
||||||
|
|
||||||
var commonTemplate = template.Must(template.New("mobileconfig").Parse(`<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
||||||
<plist version="1.0">
|
|
||||||
<dict>
|
|
||||||
<key>PayloadUUID</key>
|
|
||||||
<string>{{.UUID}}</string>
|
|
||||||
<key>PayloadDisplayName</key>
|
|
||||||
<string>Headscale</string>
|
|
||||||
<key>PayloadDescription</key>
|
|
||||||
<string>Configure Tailscale login server to: {{.Url}}</string>
|
|
||||||
<key>PayloadIdentifier</key>
|
|
||||||
<string>com.github.juanfont.headscale</string>
|
|
||||||
<key>PayloadRemovalDisallowed</key>
|
|
||||||
<false/>
|
|
||||||
<key>PayloadType</key>
|
|
||||||
<string>Configuration</string>
|
|
||||||
<key>PayloadVersion</key>
|
|
||||||
<integer>1</integer>
|
|
||||||
<key>PayloadContent</key>
|
|
||||||
<array>
|
|
||||||
{{.Payload}}
|
|
||||||
</array>
|
|
||||||
</dict>
|
|
||||||
</plist>`))
|
|
||||||
|
|
||||||
var iosTemplate = template.Must(template.New("iosTemplate").Parse(`
|
|
||||||
<dict>
|
|
||||||
<key>PayloadType</key>
|
|
||||||
<string>io.tailscale.ipn.ios</string>
|
|
||||||
<key>PayloadUUID</key>
|
|
||||||
<string>{{.UUID}}</string>
|
|
||||||
<key>PayloadIdentifier</key>
|
|
||||||
<string>com.github.juanfont.headscale</string>
|
|
||||||
<key>PayloadVersion</key>
|
|
||||||
<integer>1</integer>
|
|
||||||
<key>PayloadEnabled</key>
|
|
||||||
<true/>
|
|
||||||
|
|
||||||
<key>ControlURL</key>
|
|
||||||
<string>{{.Url}}</string>
|
|
||||||
</dict>
|
|
||||||
`))
|
|
||||||
|
|
||||||
var macosTemplate = template.Must(template.New("macosTemplate").Parse(`
|
|
||||||
<dict>
|
|
||||||
<key>PayloadType</key>
|
|
||||||
<string>io.tailscale.ipn.macos</string>
|
|
||||||
<key>PayloadUUID</key>
|
|
||||||
<string>{{.UUID}}</string>
|
|
||||||
<key>PayloadIdentifier</key>
|
|
||||||
<string>com.github.juanfont.headscale</string>
|
|
||||||
<key>PayloadVersion</key>
|
|
||||||
<integer>1</integer>
|
|
||||||
<key>PayloadEnabled</key>
|
|
||||||
<true/>
|
|
||||||
|
|
||||||
<key>ControlURL</key>
|
|
||||||
<string>{{.Url}}</string>
|
|
||||||
</dict>
|
|
||||||
`))
|
|
||||||
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
|
|
||||||
}
|
|
||||||
31
cli_test.go
31
cli_test.go
@@ -1,31 +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,
|
|
||||||
IPAddress: "10.0.0.1",
|
|
||||||
}
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
163
cmd/gh-action-integration-generator/main.go
Normal file
163
cmd/gh-action-integration-generator/main.go
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
//go:generate go run ./main.go
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"text/template"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
githubWorkflowPath = "../../.github/workflows/"
|
||||||
|
jobFileNameTemplate = `test-integration-v2-%s.yaml`
|
||||||
|
jobTemplate = template.Must(
|
||||||
|
template.New("jobTemplate").
|
||||||
|
Parse(`# DO NOT EDIT, generated with cmd/gh-action-integration-generator/main.go
|
||||||
|
# To regenerate, run "go generate" in cmd/gh-action-integration-generator/
|
||||||
|
|
||||||
|
name: Integration Test v2 - {{.Name}}
|
||||||
|
|
||||||
|
on: [pull_request]
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: {{ "${{ github.workflow }}-$${{ github.head_ref || github.run_id }}" }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
with:
|
||||||
|
fetch-depth: 2
|
||||||
|
|
||||||
|
- name: Get changed files
|
||||||
|
id: changed-files
|
||||||
|
uses: tj-actions/changed-files@v34
|
||||||
|
with:
|
||||||
|
files: |
|
||||||
|
*.nix
|
||||||
|
go.*
|
||||||
|
**/*.go
|
||||||
|
integration_test/
|
||||||
|
config-example.yaml
|
||||||
|
|
||||||
|
- uses: cachix/install-nix-action@v18
|
||||||
|
if: {{ "${{ env.ACT }}" }} || steps.changed-files.outputs.any_changed == 'true'
|
||||||
|
|
||||||
|
- name: Run general integration tests
|
||||||
|
if: steps.changed-files.outputs.any_changed == 'true'
|
||||||
|
run: |
|
||||||
|
nix develop --command -- docker run \
|
||||||
|
--tty --rm \
|
||||||
|
--volume ~/.cache/hs-integration-go:/go \
|
||||||
|
--name headscale-test-suite \
|
||||||
|
--volume $PWD:$PWD -w $PWD/integration \
|
||||||
|
--volume /var/run/docker.sock:/var/run/docker.sock \
|
||||||
|
--volume $PWD/control_logs:/tmp/control \
|
||||||
|
golang:1 \
|
||||||
|
go test ./... \
|
||||||
|
-tags ts2019 \
|
||||||
|
-failfast \
|
||||||
|
-timeout 120m \
|
||||||
|
-parallel 1 \
|
||||||
|
-run "^{{.Name}}$"
|
||||||
|
|
||||||
|
- uses: actions/upload-artifact@v3
|
||||||
|
if: always() && steps.changed-files.outputs.any_changed == 'true'
|
||||||
|
with:
|
||||||
|
name: logs
|
||||||
|
path: "control_logs/*.log"
|
||||||
|
`),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
const workflowFilePerm = 0o600
|
||||||
|
|
||||||
|
func removeTests() {
|
||||||
|
glob := fmt.Sprintf(jobFileNameTemplate, "*")
|
||||||
|
|
||||||
|
files, err := filepath.Glob(filepath.Join(githubWorkflowPath, glob))
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("failed to find test files")
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, file := range files {
|
||||||
|
err := os.Remove(file)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("failed to remove: %s", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func findTests() []string {
|
||||||
|
rgBin, err := exec.LookPath("rg")
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("failed to find rg (ripgrep) binary")
|
||||||
|
}
|
||||||
|
|
||||||
|
args := []string{
|
||||||
|
"--regexp", "func (Test.+)\\(.*",
|
||||||
|
"../../integration/",
|
||||||
|
"--replace", "$1",
|
||||||
|
"--sort", "path",
|
||||||
|
"--no-line-number",
|
||||||
|
"--no-filename",
|
||||||
|
"--no-heading",
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("executing: %s %s", rgBin, strings.Join(args, " "))
|
||||||
|
|
||||||
|
ripgrep := exec.Command(
|
||||||
|
rgBin,
|
||||||
|
args...,
|
||||||
|
)
|
||||||
|
|
||||||
|
result, err := ripgrep.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("out: %s", result)
|
||||||
|
log.Fatalf("failed to run ripgrep: %s", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tests := strings.Split(string(result), "\n")
|
||||||
|
tests = tests[:len(tests)-1]
|
||||||
|
|
||||||
|
return tests
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
type testConfig struct {
|
||||||
|
Name string
|
||||||
|
}
|
||||||
|
|
||||||
|
tests := findTests()
|
||||||
|
|
||||||
|
removeTests()
|
||||||
|
|
||||||
|
for _, test := range tests {
|
||||||
|
log.Printf("generating workflow for %s", test)
|
||||||
|
|
||||||
|
var content bytes.Buffer
|
||||||
|
|
||||||
|
if err := jobTemplate.Execute(&content, testConfig{
|
||||||
|
Name: test,
|
||||||
|
}); err != nil {
|
||||||
|
log.Fatalf("failed to render template: %s", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
testPath := path.Join(githubWorkflowPath, fmt.Sprintf(jobFileNameTemplate, test))
|
||||||
|
|
||||||
|
err := os.WriteFile(testPath, content.Bytes(), workflowFilePerm)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("failed to write github job: %s", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
201
cmd/headscale/cli/api_key.go
Normal file
201
cmd/headscale/cli/api_key.go
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/juanfont/headscale"
|
||||||
|
v1 "github.com/juanfont/headscale/gen/go/headscale/v1"
|
||||||
|
"github.com/prometheus/common/model"
|
||||||
|
"github.com/pterm/pterm"
|
||||||
|
"github.com/rs/zerolog/log"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
"google.golang.org/protobuf/types/known/timestamppb"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// 90 days.
|
||||||
|
DefaultAPIKeyExpiry = "90d"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
rootCmd.AddCommand(apiKeysCmd)
|
||||||
|
apiKeysCmd.AddCommand(listAPIKeys)
|
||||||
|
|
||||||
|
createAPIKeyCmd.Flags().
|
||||||
|
StringP("expiration", "e", DefaultAPIKeyExpiry, "Human-readable expiration of the key (e.g. 30m, 24h)")
|
||||||
|
|
||||||
|
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{}
|
||||||
|
|
||||||
|
durationStr, _ := cmd.Flags().GetString("expiration")
|
||||||
|
|
||||||
|
duration, err := model.ParseDuration(durationStr)
|
||||||
|
if err != nil {
|
||||||
|
ErrorOutput(
|
||||||
|
err,
|
||||||
|
fmt.Sprintf("Could not parse duration: %s\n", err),
|
||||||
|
output,
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
expiration := time.Now().UTC().Add(time.Duration(duration))
|
||||||
|
|
||||||
|
log.Trace().
|
||||||
|
Dur("expiration", time.Duration(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)
|
||||||
|
},
|
||||||
|
}
|
||||||
22
cmd/headscale/cli/configtest.go
Normal file
22
cmd/headscale/cli/configtest.go
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/rs/zerolog/log"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
rootCmd.AddCommand(configTestCmd)
|
||||||
|
}
|
||||||
|
|
||||||
|
var configTestCmd = &cobra.Command{
|
||||||
|
Use: "configtest",
|
||||||
|
Short: "Test the configuration.",
|
||||||
|
Long: "Run a test of the configuration and exit.",
|
||||||
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
|
_, err := getHeadscaleApp()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal().Caller().Err(err).Msg("Error initializing")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
138
cmd/headscale/cli/debug.go
Normal file
138
cmd/headscale/cli/debug.go
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/juanfont/headscale"
|
||||||
|
v1 "github.com/juanfont/headscale/gen/go/headscale/v1"
|
||||||
|
"github.com/rs/zerolog/log"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
"google.golang.org/grpc/status"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
errPreAuthKeyMalformed = Error("key is malformed. expected 64 hex characters with `nodekey` prefix")
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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("user", "u", "", "User")
|
||||||
|
|
||||||
|
createNodeCmd.Flags().StringP("namespace", "n", "", "User")
|
||||||
|
createNodeNamespaceFlag := createNodeCmd.Flags().Lookup("namespace")
|
||||||
|
createNodeNamespaceFlag.Deprecated = deprecateNamespaceMessage
|
||||||
|
createNodeNamespaceFlag.Hidden = true
|
||||||
|
|
||||||
|
err = createNodeCmd.MarkFlagRequired("user")
|
||||||
|
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")
|
||||||
|
|
||||||
|
user, err := cmd.Flags().GetString("user")
|
||||||
|
if err != nil {
|
||||||
|
ErrorOutput(err, fmt.Sprintf("Error getting user: %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 !headscale.NodePublicKeyRegex.Match([]byte(machineKey)) {
|
||||||
|
err = errPreAuthKeyMalformed
|
||||||
|
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,
|
||||||
|
User: user,
|
||||||
|
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)
|
||||||
|
},
|
||||||
|
}
|
||||||
28
cmd/headscale/cli/dump_config.go
Normal file
28
cmd/headscale/cli/dump_config.go
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
"github.com/spf13/viper"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
rootCmd.AddCommand(dumpConfigCmd)
|
||||||
|
}
|
||||||
|
|
||||||
|
var dumpConfigCmd = &cobra.Command{
|
||||||
|
Use: "dumpConfig",
|
||||||
|
Short: "dump current config to /etc/headscale/config.dump.yaml, integration test only",
|
||||||
|
Hidden: true,
|
||||||
|
Args: func(cmd *cobra.Command, args []string) error {
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
|
err := viper.WriteConfigAs("/etc/headscale/config.dump.yaml")
|
||||||
|
if err != nil {
|
||||||
|
//nolint
|
||||||
|
fmt.Println("Failed to dump config")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
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)
|
||||||
|
},
|
||||||
|
}
|
||||||
115
cmd/headscale/cli/mockoidc.go
Normal file
115
cmd/headscale/cli/mockoidc.go
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/oauth2-proxy/mockoidc"
|
||||||
|
"github.com/rs/zerolog/log"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
errMockOidcClientIDNotDefined = Error("MOCKOIDC_CLIENT_ID not defined")
|
||||||
|
errMockOidcClientSecretNotDefined = Error("MOCKOIDC_CLIENT_SECRET not defined")
|
||||||
|
errMockOidcPortNotDefined = Error("MOCKOIDC_PORT not defined")
|
||||||
|
refreshTTL = 60 * time.Minute
|
||||||
|
)
|
||||||
|
|
||||||
|
var accessTTL = 2 * time.Minute
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
rootCmd.AddCommand(mockOidcCmd)
|
||||||
|
}
|
||||||
|
|
||||||
|
var mockOidcCmd = &cobra.Command{
|
||||||
|
Use: "mockoidc",
|
||||||
|
Short: "Runs a mock OIDC server for testing",
|
||||||
|
Long: "This internal command runs a OpenID Connect for testing purposes",
|
||||||
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
|
err := mockOIDC()
|
||||||
|
if err != nil {
|
||||||
|
log.Error().Err(err).Msgf("Error running mock OIDC server")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
func mockOIDC() error {
|
||||||
|
clientID := os.Getenv("MOCKOIDC_CLIENT_ID")
|
||||||
|
if clientID == "" {
|
||||||
|
return errMockOidcClientIDNotDefined
|
||||||
|
}
|
||||||
|
clientSecret := os.Getenv("MOCKOIDC_CLIENT_SECRET")
|
||||||
|
if clientSecret == "" {
|
||||||
|
return errMockOidcClientSecretNotDefined
|
||||||
|
}
|
||||||
|
addrStr := os.Getenv("MOCKOIDC_ADDR")
|
||||||
|
if addrStr == "" {
|
||||||
|
return errMockOidcPortNotDefined
|
||||||
|
}
|
||||||
|
portStr := os.Getenv("MOCKOIDC_PORT")
|
||||||
|
if portStr == "" {
|
||||||
|
return errMockOidcPortNotDefined
|
||||||
|
}
|
||||||
|
accessTTLOverride := os.Getenv("MOCKOIDC_ACCESS_TTL")
|
||||||
|
if accessTTLOverride != "" {
|
||||||
|
newTTL, err := time.ParseDuration(accessTTLOverride)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
accessTTL = newTTL
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Info().Msgf("Access token TTL: %s", accessTTL)
|
||||||
|
|
||||||
|
port, err := strconv.Atoi(portStr)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
mock, err := getMockOIDC(clientID, clientSecret)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
listener, err := net.Listen("tcp", fmt.Sprintf("%s:%d", addrStr, port))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
err = mock.Start(listener, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
log.Info().Msgf("Mock OIDC server listening on %s", listener.Addr().String())
|
||||||
|
log.Info().Msgf("Issuer: %s", mock.Issuer())
|
||||||
|
c := make(chan struct{})
|
||||||
|
<-c
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func getMockOIDC(clientID string, clientSecret string) (*mockoidc.MockOIDC, error) {
|
||||||
|
keypair, err := mockoidc.NewKeypair(nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
mock := mockoidc.MockOIDC{
|
||||||
|
ClientID: clientID,
|
||||||
|
ClientSecret: clientSecret,
|
||||||
|
AccessTTL: accessTTL,
|
||||||
|
RefreshTTL: refreshTTL,
|
||||||
|
CodeChallengeMethodsSupported: []string{"plain", "S256"},
|
||||||
|
Keypair: keypair,
|
||||||
|
SessionStore: mockoidc.NewSessionStore(),
|
||||||
|
UserQueue: &mockoidc.UserQueue{},
|
||||||
|
ErrorQueue: &mockoidc.ErrorQueue{},
|
||||||
|
}
|
||||||
|
|
||||||
|
return &mock, nil
|
||||||
|
}
|
||||||
@@ -1,138 +0,0 @@
|
|||||||
package cli
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"log"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/pterm/pterm"
|
|
||||||
"github.com/spf13/cobra"
|
|
||||||
)
|
|
||||||
|
|
||||||
func init() {
|
|
||||||
rootCmd.AddCommand(namespaceCmd)
|
|
||||||
namespaceCmd.AddCommand(createNamespaceCmd)
|
|
||||||
namespaceCmd.AddCommand(listNamespacesCmd)
|
|
||||||
namespaceCmd.AddCommand(destroyNamespaceCmd)
|
|
||||||
namespaceCmd.AddCommand(renameNamespaceCmd)
|
|
||||||
}
|
|
||||||
|
|
||||||
var namespaceCmd = &cobra.Command{
|
|
||||||
Use: "namespaces",
|
|
||||||
Short: "Manage the namespaces of Headscale",
|
|
||||||
}
|
|
||||||
|
|
||||||
var createNamespaceCmd = &cobra.Command{
|
|
||||||
Use: "create NAME",
|
|
||||||
Short: "Creates a new namespace",
|
|
||||||
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) {
|
|
||||||
o, _ := cmd.Flags().GetString("output")
|
|
||||||
h, err := getHeadscaleApp()
|
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("Error initializing: %s", err)
|
|
||||||
}
|
|
||||||
namespace, err := h.CreateNamespace(args[0])
|
|
||||||
if strings.HasPrefix(o, "json") {
|
|
||||||
JsonOutput(namespace, err, o)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
fmt.Printf("Error creating namespace: %s\n", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
fmt.Printf("Namespace created\n")
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
var destroyNamespaceCmd = &cobra.Command{
|
|
||||||
Use: "destroy NAME",
|
|
||||||
Short: "Destroys a namespace",
|
|
||||||
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) {
|
|
||||||
o, _ := cmd.Flags().GetString("output")
|
|
||||||
h, err := getHeadscaleApp()
|
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("Error initializing: %s", err)
|
|
||||||
}
|
|
||||||
err = h.DestroyNamespace(args[0])
|
|
||||||
if strings.HasPrefix(o, "json") {
|
|
||||||
JsonOutput(map[string]string{"Result": "Namespace destroyed"}, err, o)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
fmt.Printf("Error destroying namespace: %s\n", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
fmt.Printf("Namespace destroyed\n")
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
var listNamespacesCmd = &cobra.Command{
|
|
||||||
Use: "list",
|
|
||||||
Short: "List all the namespaces",
|
|
||||||
Run: func(cmd *cobra.Command, args []string) {
|
|
||||||
o, _ := cmd.Flags().GetString("output")
|
|
||||||
h, err := getHeadscaleApp()
|
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("Error initializing: %s", err)
|
|
||||||
}
|
|
||||||
namespaces, err := h.ListNamespaces()
|
|
||||||
if strings.HasPrefix(o, "json") {
|
|
||||||
JsonOutput(namespaces, err, o)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
fmt.Println(err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
d := pterm.TableData{{"ID", "Name", "Created"}}
|
|
||||||
for _, n := range *namespaces {
|
|
||||||
d = append(d, []string{strconv.FormatUint(uint64(n.ID), 10), n.Name, n.CreatedAt.Format("2006-01-02 15:04:05")})
|
|
||||||
}
|
|
||||||
err = pterm.DefaultTable.WithHasHeader().WithData(d).Render()
|
|
||||||
if err != nil {
|
|
||||||
log.Fatal(err)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
var renameNamespaceCmd = &cobra.Command{
|
|
||||||
Use: "rename OLD_NAME NEW_NAME",
|
|
||||||
Short: "Renames a namespace",
|
|
||||||
Args: func(cmd *cobra.Command, args []string) error {
|
|
||||||
if len(args) < 2 {
|
|
||||||
return fmt.Errorf("Missing parameters")
|
|
||||||
}
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
err = h.RenameNamespace(args[0], args[1])
|
|
||||||
if strings.HasPrefix(o, "json") {
|
|
||||||
JsonOutput(map[string]string{"Result": "Namespace renamed"}, err, o)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
fmt.Printf("Error renaming namespace: %s\n", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
fmt.Printf("Namespace renamed\n")
|
|
||||||
},
|
|
||||||
}
|
|
||||||
@@ -3,152 +3,369 @@ package cli
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
|
"net/netip"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
survey "github.com/AlecAivazis/survey/v2"
|
survey "github.com/AlecAivazis/survey/v2"
|
||||||
"github.com/juanfont/headscale"
|
"github.com/juanfont/headscale"
|
||||||
|
v1 "github.com/juanfont/headscale/gen/go/headscale/v1"
|
||||||
"github.com/pterm/pterm"
|
"github.com/pterm/pterm"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
"tailscale.com/tailcfg"
|
"google.golang.org/grpc/status"
|
||||||
"tailscale.com/types/wgkey"
|
"tailscale.com/types/key"
|
||||||
)
|
)
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
rootCmd.AddCommand(nodeCmd)
|
rootCmd.AddCommand(nodeCmd)
|
||||||
nodeCmd.PersistentFlags().StringP("namespace", "n", "", "Namespace")
|
listNodesCmd.Flags().StringP("user", "u", "", "Filter by user")
|
||||||
err := nodeCmd.MarkPersistentFlagRequired("namespace")
|
listNodesCmd.Flags().BoolP("tags", "t", false, "Show tags")
|
||||||
|
|
||||||
|
listNodesCmd.Flags().StringP("namespace", "n", "", "User")
|
||||||
|
listNodesNamespaceFlag := listNodesCmd.Flags().Lookup("namespace")
|
||||||
|
listNodesNamespaceFlag.Deprecated = deprecateNamespaceMessage
|
||||||
|
listNodesNamespaceFlag.Hidden = true
|
||||||
|
|
||||||
|
nodeCmd.AddCommand(listNodesCmd)
|
||||||
|
|
||||||
|
registerNodeCmd.Flags().StringP("user", "u", "", "User")
|
||||||
|
|
||||||
|
registerNodeCmd.Flags().StringP("namespace", "n", "", "User")
|
||||||
|
registerNodeNamespaceFlag := registerNodeCmd.Flags().Lookup("namespace")
|
||||||
|
registerNodeNamespaceFlag.Deprecated = deprecateNamespaceMessage
|
||||||
|
registerNodeNamespaceFlag.Hidden = true
|
||||||
|
|
||||||
|
err := registerNodeCmd.MarkFlagRequired("user")
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf(err.Error())
|
||||||
|
}
|
||||||
|
registerNodeCmd.Flags().StringP("key", "k", "", "Key")
|
||||||
|
err = registerNodeCmd.MarkFlagRequired("key")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf(err.Error())
|
log.Fatalf(err.Error())
|
||||||
}
|
}
|
||||||
nodeCmd.AddCommand(listNodesCmd)
|
|
||||||
nodeCmd.AddCommand(registerNodeCmd)
|
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)
|
||||||
|
|
||||||
|
renameNodeCmd.Flags().Uint64P("identifier", "i", 0, "Node identifier (ID)")
|
||||||
|
err = renameNodeCmd.MarkFlagRequired("identifier")
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf(err.Error())
|
||||||
|
}
|
||||||
|
nodeCmd.AddCommand(renameNodeCmd)
|
||||||
|
|
||||||
|
deleteNodeCmd.Flags().Uint64P("identifier", "i", 0, "Node identifier (ID)")
|
||||||
|
err = deleteNodeCmd.MarkFlagRequired("identifier")
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf(err.Error())
|
||||||
|
}
|
||||||
nodeCmd.AddCommand(deleteNodeCmd)
|
nodeCmd.AddCommand(deleteNodeCmd)
|
||||||
nodeCmd.AddCommand(shareMachineCmd)
|
|
||||||
nodeCmd.AddCommand(unshareMachineCmd)
|
moveNodeCmd.Flags().Uint64P("identifier", "i", 0, "Node identifier (ID)")
|
||||||
|
|
||||||
|
err = moveNodeCmd.MarkFlagRequired("identifier")
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf(err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
moveNodeCmd.Flags().StringP("user", "u", "", "New user")
|
||||||
|
|
||||||
|
moveNodeCmd.Flags().StringP("namespace", "n", "", "User")
|
||||||
|
moveNodeNamespaceFlag := moveNodeCmd.Flags().Lookup("namespace")
|
||||||
|
moveNodeNamespaceFlag.Deprecated = deprecateNamespaceMessage
|
||||||
|
moveNodeNamespaceFlag.Hidden = true
|
||||||
|
|
||||||
|
err = moveNodeCmd.MarkFlagRequired("user")
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf(err.Error())
|
||||||
|
}
|
||||||
|
nodeCmd.AddCommand(moveNodeCmd)
|
||||||
|
|
||||||
|
tagCmd.Flags().Uint64P("identifier", "i", 0, "Node identifier (ID)")
|
||||||
|
|
||||||
|
err = tagCmd.MarkFlagRequired("identifier")
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf(err.Error())
|
||||||
|
}
|
||||||
|
tagCmd.Flags().
|
||||||
|
StringSliceP("tags", "t", []string{}, "List of tags to add to the node")
|
||||||
|
nodeCmd.AddCommand(tagCmd)
|
||||||
}
|
}
|
||||||
|
|
||||||
var nodeCmd = &cobra.Command{
|
var nodeCmd = &cobra.Command{
|
||||||
Use: "nodes",
|
Use: "nodes",
|
||||||
Short: "Manage the nodes of Headscale",
|
Short: "Manage the nodes of Headscale",
|
||||||
|
Aliases: []string{"node", "machine", "machines"},
|
||||||
}
|
}
|
||||||
|
|
||||||
var registerNodeCmd = &cobra.Command{
|
var registerNodeCmd = &cobra.Command{
|
||||||
Use: "register machineID",
|
Use: "register",
|
||||||
Short: "Registers a machine to your network",
|
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) {
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
n, err := cmd.Flags().GetString("namespace")
|
output, _ := cmd.Flags().GetString("output")
|
||||||
|
user, err := cmd.Flags().GetString("user")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("Error getting namespace: %s", err)
|
ErrorOutput(err, fmt.Sprintf("Error getting user: %s", err), output)
|
||||||
}
|
|
||||||
o, _ := cmd.Flags().GetString("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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ctx, client, conn, cancel := getHeadscaleCLIClient()
|
||||||
|
defer cancel()
|
||||||
|
defer conn.Close()
|
||||||
|
|
||||||
|
machineKey, err := cmd.Flags().GetString("key")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Printf("Cannot register machine: %s\n", err)
|
ErrorOutput(
|
||||||
|
err,
|
||||||
|
fmt.Sprintf("Error getting node key from flag: %s", err),
|
||||||
|
output,
|
||||||
|
)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
fmt.Printf("Machine registered\n")
|
|
||||||
|
request := &v1.RegisterMachineRequest{
|
||||||
|
Key: machineKey,
|
||||||
|
User: user,
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
fmt.Sprintf("Machine %s registered", response.Machine.GivenName), output)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
var listNodesCmd = &cobra.Command{
|
var listNodesCmd = &cobra.Command{
|
||||||
Use: "list",
|
Use: "list",
|
||||||
Short: "List the nodes in a given namespace",
|
Short: "List nodes",
|
||||||
|
Aliases: []string{"ls", "show"},
|
||||||
Run: func(cmd *cobra.Command, args []string) {
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
n, err := cmd.Flags().GetString("namespace")
|
output, _ := cmd.Flags().GetString("output")
|
||||||
|
user, err := cmd.Flags().GetString("user")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("Error getting namespace: %s", err)
|
ErrorOutput(err, fmt.Sprintf("Error getting user: %s", err), output)
|
||||||
}
|
|
||||||
o, _ := cmd.Flags().GetString("output")
|
|
||||||
|
|
||||||
h, err := getHeadscaleApp()
|
return
|
||||||
|
}
|
||||||
|
showTags, err := cmd.Flags().GetBool("tags")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("Error initializing: %s", err)
|
ErrorOutput(err, fmt.Sprintf("Error getting tags flag: %s", err), output)
|
||||||
}
|
|
||||||
|
|
||||||
namespace, err := h.GetNamespace(n)
|
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("Error fetching namespace: %s", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
machines, err := h.ListMachinesInNamespace(n)
|
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("Error fetching machines: %s", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
sharedMachines, err := h.ListSharedMachinesInNamespace(n)
|
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("Error fetching shared machines: %s", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
allMachines := append(*machines, *sharedMachines...)
|
|
||||||
|
|
||||||
if strings.HasPrefix(o, "json") {
|
|
||||||
JsonOutput(allMachines, err, o)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if err != nil {
|
ctx, client, conn, cancel := getHeadscaleCLIClient()
|
||||||
log.Fatalf("Error getting nodes: %s", err)
|
defer cancel()
|
||||||
|
defer conn.Close()
|
||||||
|
|
||||||
|
request := &v1.ListMachinesRequest{
|
||||||
|
User: user,
|
||||||
}
|
}
|
||||||
|
|
||||||
d, err := nodesToPtables(*namespace, allMachines)
|
response, err := client.ListMachines(ctx, request)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("Error converting to table: %s", err)
|
ErrorOutput(
|
||||||
|
err,
|
||||||
|
fmt.Sprintf("Cannot get nodes: %s", status.Convert(err).Message()),
|
||||||
|
output,
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
err = pterm.DefaultTable.WithHasHeader().WithData(d).Render()
|
if output != "" {
|
||||||
|
SuccessOutput(response.Machines, "", output)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
tableData, err := nodesToPtables(user, showTags, response.Machines)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal(err)
|
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 deleteNodeCmd = &cobra.Command{
|
var expireNodeCmd = &cobra.Command{
|
||||||
Use: "delete ID",
|
Use: "expire",
|
||||||
Short: "Delete a node",
|
Short: "Expire (log out) a machine in your network",
|
||||||
Args: func(cmd *cobra.Command, args []string) error {
|
Long: "Expiring a node will keep the node in the database and force it to reauthenticate.",
|
||||||
if len(args) < 1 {
|
Aliases: []string{"logout", "exp", "e"},
|
||||||
return fmt.Errorf("missing parameters")
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
},
|
|
||||||
Run: func(cmd *cobra.Command, args []string) {
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
output, _ := cmd.Flags().GetString("output")
|
output, _ := cmd.Flags().GetString("output")
|
||||||
h, err := getHeadscaleApp()
|
|
||||||
|
identifier, err := cmd.Flags().GetUint64("identifier")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("Error initializing: %s", err)
|
ErrorOutput(
|
||||||
|
err,
|
||||||
|
fmt.Sprintf("Error converting ID to integer: %s", err),
|
||||||
|
output,
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
}
|
}
|
||||||
id, err := strconv.Atoi(args[0])
|
|
||||||
if err != nil {
|
ctx, client, conn, cancel := getHeadscaleCLIClient()
|
||||||
log.Fatalf("Error converting ID to integer: %s", err)
|
defer cancel()
|
||||||
|
defer conn.Close()
|
||||||
|
|
||||||
|
request := &v1.ExpireMachineRequest{
|
||||||
|
MachineId: identifier,
|
||||||
}
|
}
|
||||||
m, err := h.GetMachineByID(uint64(id))
|
|
||||||
|
response, err := client.ExpireMachine(ctx, request)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("Error getting node: %s", err)
|
ErrorOutput(
|
||||||
|
err,
|
||||||
|
fmt.Sprintf(
|
||||||
|
"Cannot expire machine: %s\n",
|
||||||
|
status.Convert(err).Message(),
|
||||||
|
),
|
||||||
|
output,
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
SuccessOutput(response.Machine, "Machine expired", output)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var renameNodeCmd = &cobra.Command{
|
||||||
|
Use: "rename NEW_NAME",
|
||||||
|
Short: "Renames a machine in your network",
|
||||||
|
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()
|
||||||
|
|
||||||
|
newName := ""
|
||||||
|
if len(args) > 0 {
|
||||||
|
newName = args[0]
|
||||||
|
}
|
||||||
|
request := &v1.RenameMachineRequest{
|
||||||
|
MachineId: identifier,
|
||||||
|
NewName: newName,
|
||||||
|
}
|
||||||
|
|
||||||
|
response, err := client.RenameMachine(ctx, request)
|
||||||
|
if err != nil {
|
||||||
|
ErrorOutput(
|
||||||
|
err,
|
||||||
|
fmt.Sprintf(
|
||||||
|
"Cannot rename machine: %s\n",
|
||||||
|
status.Convert(err).Message(),
|
||||||
|
),
|
||||||
|
output,
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
SuccessOutput(response.Machine, "Machine renamed", 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
|
confirm := false
|
||||||
force, _ := cmd.Flags().GetBool("force")
|
force, _ := cmd.Flags().GetBool("force")
|
||||||
if !force {
|
if !force {
|
||||||
prompt := &survey.Confirm{
|
prompt := &survey.Confirm{
|
||||||
Message: fmt.Sprintf("Do you want to remove the node %s?", m.Name),
|
Message: fmt.Sprintf(
|
||||||
|
"Do you want to remove the node %s?",
|
||||||
|
getResponse.GetMachine().Name,
|
||||||
|
),
|
||||||
}
|
}
|
||||||
err = survey.AskOne(prompt, &confirm)
|
err = survey.AskOne(prompt, &confirm)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -157,162 +374,307 @@ var deleteNodeCmd = &cobra.Command{
|
|||||||
}
|
}
|
||||||
|
|
||||||
if confirm || force {
|
if confirm || force {
|
||||||
err = h.DeleteMachine(m)
|
response, err := client.DeleteMachine(ctx, deleteRequest)
|
||||||
if strings.HasPrefix(output, "json") {
|
if output != "" {
|
||||||
JsonOutput(map[string]string{"Result": "Node deleted"}, err, output)
|
SuccessOutput(response, "", output)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("Error deleting node: %s", err)
|
ErrorOutput(
|
||||||
}
|
err,
|
||||||
fmt.Printf("Node deleted\n")
|
fmt.Sprintf(
|
||||||
} else {
|
"Error deleting node: %s",
|
||||||
if strings.HasPrefix(output, "json") {
|
status.Convert(err).Message(),
|
||||||
JsonOutput(map[string]string{"Result": "Node not deleted"}, err, output)
|
),
|
||||||
|
output,
|
||||||
|
)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
fmt.Printf("Node not deleted\n")
|
SuccessOutput(
|
||||||
|
map[string]string{"Result": "Node deleted"},
|
||||||
|
"Node deleted",
|
||||||
|
output,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
SuccessOutput(map[string]string{"Result": "Node not deleted"}, "Node not deleted", output)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
var shareMachineCmd = &cobra.Command{
|
var moveNodeCmd = &cobra.Command{
|
||||||
Use: "share ID namespace",
|
Use: "move",
|
||||||
Short: "Shares a node from the current namespace to the specified one",
|
Short: "Move node to another user",
|
||||||
Args: func(cmd *cobra.Command, args []string) error {
|
Aliases: []string{"mv"},
|
||||||
if len(args) < 2 {
|
|
||||||
return fmt.Errorf("missing parameters")
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
},
|
|
||||||
Run: func(cmd *cobra.Command, args []string) {
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
namespace, err := cmd.Flags().GetString("namespace")
|
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("Error getting namespace: %s", err)
|
|
||||||
}
|
|
||||||
output, _ := cmd.Flags().GetString("output")
|
output, _ := cmd.Flags().GetString("output")
|
||||||
|
|
||||||
h, err := getHeadscaleApp()
|
identifier, err := cmd.Flags().GetUint64("identifier")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("Error initializing: %s", err)
|
ErrorOutput(
|
||||||
}
|
err,
|
||||||
|
fmt.Sprintf("Error converting ID to integer: %s", err),
|
||||||
|
output,
|
||||||
|
)
|
||||||
|
|
||||||
_, err = h.GetNamespace(namespace)
|
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("Error fetching origin namespace: %s", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
destinationNamespace, err := h.GetNamespace(args[1])
|
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("Error fetching destination namespace: %s", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
id, err := strconv.Atoi(args[0])
|
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("Error converting ID to integer: %s", err)
|
|
||||||
}
|
|
||||||
machine, err := h.GetMachineByID(uint64(id))
|
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("Error getting node: %s", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
err = h.AddSharedMachineToNamespace(machine, destinationNamespace)
|
|
||||||
if strings.HasPrefix(output, "json") {
|
|
||||||
JsonOutput(map[string]string{"Result": "Node shared"}, err, output)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
fmt.Printf("Error sharing node: %s\n", err)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Println("Node shared!")
|
user, err := cmd.Flags().GetString("user")
|
||||||
|
if err != nil {
|
||||||
|
ErrorOutput(
|
||||||
|
err,
|
||||||
|
fmt.Sprintf("Error getting user: %s", err),
|
||||||
|
output,
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, client, conn, cancel := getHeadscaleCLIClient()
|
||||||
|
defer cancel()
|
||||||
|
defer conn.Close()
|
||||||
|
|
||||||
|
getRequest := &v1.GetMachineRequest{
|
||||||
|
MachineId: identifier,
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = client.GetMachine(ctx, getRequest)
|
||||||
|
if err != nil {
|
||||||
|
ErrorOutput(
|
||||||
|
err,
|
||||||
|
fmt.Sprintf(
|
||||||
|
"Error getting node: %s",
|
||||||
|
status.Convert(err).Message(),
|
||||||
|
),
|
||||||
|
output,
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
moveRequest := &v1.MoveMachineRequest{
|
||||||
|
MachineId: identifier,
|
||||||
|
User: user,
|
||||||
|
}
|
||||||
|
|
||||||
|
moveResponse, err := client.MoveMachine(ctx, moveRequest)
|
||||||
|
if err != nil {
|
||||||
|
ErrorOutput(
|
||||||
|
err,
|
||||||
|
fmt.Sprintf(
|
||||||
|
"Error moving node: %s",
|
||||||
|
status.Convert(err).Message(),
|
||||||
|
),
|
||||||
|
output,
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
SuccessOutput(moveResponse.Machine, "Node moved to another user", output)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
var unshareMachineCmd = &cobra.Command{
|
func nodesToPtables(
|
||||||
Use: "unshare ID",
|
currentUser string,
|
||||||
Short: "Unshares a node from the specified namespace",
|
showTags bool,
|
||||||
Args: func(cmd *cobra.Command, args []string) error {
|
machines []*v1.Machine,
|
||||||
if len(args) < 1 {
|
) (pterm.TableData, error) {
|
||||||
return fmt.Errorf("missing parameters")
|
tableHeader := []string{
|
||||||
}
|
"ID",
|
||||||
return nil
|
"Hostname",
|
||||||
},
|
"Name",
|
||||||
Run: func(cmd *cobra.Command, args []string) {
|
"MachineKey",
|
||||||
namespace, err := cmd.Flags().GetString("namespace")
|
"NodeKey",
|
||||||
if err != nil {
|
"User",
|
||||||
log.Fatalf("Error getting namespace: %s", err)
|
"IP addresses",
|
||||||
}
|
"Ephemeral",
|
||||||
output, _ := cmd.Flags().GetString("output")
|
"Last seen",
|
||||||
|
"Expiration",
|
||||||
h, err := getHeadscaleApp()
|
"Online",
|
||||||
if err != nil {
|
"Expired",
|
||||||
log.Fatalf("Error initializing: %s", err)
|
}
|
||||||
}
|
if showTags {
|
||||||
|
tableHeader = append(tableHeader, []string{
|
||||||
n, err := h.GetNamespace(namespace)
|
"ForcedTags",
|
||||||
if err != nil {
|
"InvalidTags",
|
||||||
log.Fatalf("Error fetching namespace: %s", err)
|
"ValidTags",
|
||||||
}
|
}...)
|
||||||
|
}
|
||||||
id, err := strconv.Atoi(args[0])
|
tableData := pterm.TableData{tableHeader}
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("Error converting ID to integer: %s", err)
|
|
||||||
}
|
|
||||||
machine, err := h.GetMachineByID(uint64(id))
|
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("Error getting node: %s", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
err = h.RemoveSharedMachineFromNamespace(machine, n)
|
|
||||||
if strings.HasPrefix(output, "json") {
|
|
||||||
JsonOutput(map[string]string{"Result": "Node unshared"}, err, output)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
fmt.Printf("Error unsharing node: %s\n", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Println("Node unshared!")
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
func nodesToPtables(currentNamespace headscale.Namespace, machines []headscale.Machine) (pterm.TableData, error) {
|
|
||||||
d := pterm.TableData{{"ID", "Name", "NodeKey", "Namespace", "IP address", "Ephemeral", "Last seen", "Online"}}
|
|
||||||
|
|
||||||
for _, machine := range machines {
|
for _, machine := range machines {
|
||||||
var ephemeral bool
|
var ephemeral bool
|
||||||
if machine.AuthKey != nil && machine.AuthKey.Ephemeral {
|
if machine.PreAuthKey != nil && machine.PreAuthKey.Ephemeral {
|
||||||
ephemeral = true
|
ephemeral = true
|
||||||
}
|
}
|
||||||
|
|
||||||
var lastSeen time.Time
|
var lastSeen time.Time
|
||||||
var lastSeenTime string
|
var lastSeenTime string
|
||||||
if machine.LastSeen != nil {
|
if machine.LastSeen != nil {
|
||||||
lastSeen = *machine.LastSeen
|
lastSeen = machine.LastSeen.AsTime()
|
||||||
lastSeenTime = lastSeen.Format("2006-01-02 15:04:05")
|
lastSeenTime = lastSeen.Format("2006-01-02 15:04:05")
|
||||||
}
|
}
|
||||||
nKey, err := wgkey.ParseHex(machine.NodeKey)
|
|
||||||
|
var expiry time.Time
|
||||||
|
var expiryTime string
|
||||||
|
if machine.Expiry != nil {
|
||||||
|
expiry = machine.Expiry.AsTime()
|
||||||
|
expiryTime = expiry.Format("2006-01-02 15:04:05")
|
||||||
|
} else {
|
||||||
|
expiryTime = "N/A"
|
||||||
|
}
|
||||||
|
|
||||||
|
var machineKey key.MachinePublic
|
||||||
|
err := machineKey.UnmarshalText(
|
||||||
|
[]byte(headscale.MachinePublicKeyEnsurePrefix(machine.MachineKey)),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
machineKey = key.MachinePublic{}
|
||||||
|
}
|
||||||
|
|
||||||
|
var nodeKey key.NodePublic
|
||||||
|
err = nodeKey.UnmarshalText(
|
||||||
|
[]byte(headscale.NodePublicKeyEnsurePrefix(machine.NodeKey)),
|
||||||
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
nodeKey := tailcfg.NodeKey(nKey)
|
|
||||||
|
|
||||||
var online string
|
var online string
|
||||||
if lastSeen.After(time.Now().Add(-5 * time.Minute)) { // TODO: Find a better way to reliably show if online
|
if machine.Online {
|
||||||
online = pterm.LightGreen("true")
|
online = pterm.LightGreen("online")
|
||||||
} else {
|
} else {
|
||||||
online = pterm.LightRed("false")
|
online = pterm.LightRed("offline")
|
||||||
}
|
}
|
||||||
|
|
||||||
var namespace string
|
var expired string
|
||||||
if currentNamespace.ID == machine.NamespaceID {
|
if expiry.IsZero() || expiry.After(time.Now()) {
|
||||||
namespace = pterm.LightMagenta(machine.Namespace.Name)
|
expired = pterm.LightGreen("no")
|
||||||
} else {
|
} else {
|
||||||
namespace = pterm.LightYellow(machine.Namespace.Name)
|
expired = pterm.LightRed("yes")
|
||||||
}
|
}
|
||||||
d = append(d, []string{strconv.FormatUint(machine.ID, 10), machine.Name, nodeKey.ShortString(), namespace, machine.IPAddress, strconv.FormatBool(ephemeral), lastSeenTime, online})
|
|
||||||
|
var forcedTags string
|
||||||
|
for _, tag := range machine.ForcedTags {
|
||||||
|
forcedTags += "," + tag
|
||||||
|
}
|
||||||
|
forcedTags = strings.TrimLeft(forcedTags, ",")
|
||||||
|
var invalidTags string
|
||||||
|
for _, tag := range machine.InvalidTags {
|
||||||
|
if !contains(machine.ForcedTags, tag) {
|
||||||
|
invalidTags += "," + pterm.LightRed(tag)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
invalidTags = strings.TrimLeft(invalidTags, ",")
|
||||||
|
var validTags string
|
||||||
|
for _, tag := range machine.ValidTags {
|
||||||
|
if !contains(machine.ForcedTags, tag) {
|
||||||
|
validTags += "," + pterm.LightGreen(tag)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
validTags = strings.TrimLeft(validTags, ",")
|
||||||
|
|
||||||
|
var user string
|
||||||
|
if currentUser == "" || (currentUser == machine.User.Name) {
|
||||||
|
user = pterm.LightMagenta(machine.User.Name)
|
||||||
|
} else {
|
||||||
|
// Shared into this user
|
||||||
|
user = pterm.LightYellow(machine.User.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
var IPV4Address string
|
||||||
|
var IPV6Address string
|
||||||
|
for _, addr := range machine.IpAddresses {
|
||||||
|
if netip.MustParseAddr(addr).Is4() {
|
||||||
|
IPV4Address = addr
|
||||||
|
} else {
|
||||||
|
IPV6Address = addr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
nodeData := []string{
|
||||||
|
strconv.FormatUint(machine.Id, headscale.Base10),
|
||||||
|
machine.Name,
|
||||||
|
machine.GetGivenName(),
|
||||||
|
machineKey.ShortString(),
|
||||||
|
nodeKey.ShortString(),
|
||||||
|
user,
|
||||||
|
strings.Join([]string{IPV4Address, IPV6Address}, ", "),
|
||||||
|
strconv.FormatBool(ephemeral),
|
||||||
|
lastSeenTime,
|
||||||
|
expiryTime,
|
||||||
|
online,
|
||||||
|
expired,
|
||||||
|
}
|
||||||
|
if showTags {
|
||||||
|
nodeData = append(nodeData, []string{forcedTags, invalidTags, validTags}...)
|
||||||
|
}
|
||||||
|
tableData = append(
|
||||||
|
tableData,
|
||||||
|
nodeData,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
return d, nil
|
|
||||||
|
return tableData, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var tagCmd = &cobra.Command{
|
||||||
|
Use: "tag",
|
||||||
|
Short: "Manage the tags of a node",
|
||||||
|
Aliases: []string{"tags", "t"},
|
||||||
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
|
output, _ := cmd.Flags().GetString("output")
|
||||||
|
ctx, client, conn, cancel := getHeadscaleCLIClient()
|
||||||
|
defer cancel()
|
||||||
|
defer conn.Close()
|
||||||
|
|
||||||
|
// retrieve flags from CLI
|
||||||
|
identifier, err := cmd.Flags().GetUint64("identifier")
|
||||||
|
if err != nil {
|
||||||
|
ErrorOutput(
|
||||||
|
err,
|
||||||
|
fmt.Sprintf("Error converting ID to integer: %s", err),
|
||||||
|
output,
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tagsToSet, err := cmd.Flags().GetStringSlice("tags")
|
||||||
|
if err != nil {
|
||||||
|
ErrorOutput(
|
||||||
|
err,
|
||||||
|
fmt.Sprintf("Error retrieving list of tags to add to machine, %v", err),
|
||||||
|
output,
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sending tags to machine
|
||||||
|
request := &v1.SetTagsRequest{
|
||||||
|
MachineId: identifier,
|
||||||
|
Tags: tagsToSet,
|
||||||
|
}
|
||||||
|
resp, err := client.SetTags(ctx, request)
|
||||||
|
if err != nil {
|
||||||
|
ErrorOutput(
|
||||||
|
err,
|
||||||
|
fmt.Sprintf("Error while sending tags to headscale: %s", err),
|
||||||
|
output,
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp != nil {
|
||||||
|
SuccessOutput(
|
||||||
|
resp.GetMachine(),
|
||||||
|
"Machine updated",
|
||||||
|
output,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,173 +2,262 @@ package cli
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/hako/durafmt"
|
v1 "github.com/juanfont/headscale/gen/go/headscale/v1"
|
||||||
|
"github.com/prometheus/common/model"
|
||||||
"github.com/pterm/pterm"
|
"github.com/pterm/pterm"
|
||||||
|
"github.com/rs/zerolog/log"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
"google.golang.org/protobuf/types/known/timestamppb"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
DefaultPreAuthKeyExpiry = "1h"
|
||||||
)
|
)
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
rootCmd.AddCommand(preauthkeysCmd)
|
rootCmd.AddCommand(preauthkeysCmd)
|
||||||
preauthkeysCmd.PersistentFlags().StringP("namespace", "n", "", "Namespace")
|
preauthkeysCmd.PersistentFlags().StringP("user", "u", "", "User")
|
||||||
err := preauthkeysCmd.MarkPersistentFlagRequired("namespace")
|
|
||||||
|
preauthkeysCmd.PersistentFlags().StringP("namespace", "n", "", "User")
|
||||||
|
pakNamespaceFlag := preauthkeysCmd.PersistentFlags().Lookup("namespace")
|
||||||
|
pakNamespaceFlag.Deprecated = deprecateNamespaceMessage
|
||||||
|
pakNamespaceFlag.Hidden = true
|
||||||
|
|
||||||
|
err := preauthkeysCmd.MarkPersistentFlagRequired("user")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf(err.Error())
|
log.Fatal().Err(err).Msg("")
|
||||||
}
|
}
|
||||||
preauthkeysCmd.AddCommand(listPreAuthKeys)
|
preauthkeysCmd.AddCommand(listPreAuthKeys)
|
||||||
preauthkeysCmd.AddCommand(createPreAuthKeyCmd)
|
preauthkeysCmd.AddCommand(createPreAuthKeyCmd)
|
||||||
preauthkeysCmd.AddCommand(expirePreAuthKeyCmd)
|
preauthkeysCmd.AddCommand(expirePreAuthKeyCmd)
|
||||||
createPreAuthKeyCmd.PersistentFlags().Bool("reusable", false, "Make the preauthkey reusable")
|
createPreAuthKeyCmd.PersistentFlags().
|
||||||
createPreAuthKeyCmd.PersistentFlags().Bool("ephemeral", false, "Preauthkey for ephemeral nodes")
|
Bool("reusable", false, "Make the preauthkey reusable")
|
||||||
createPreAuthKeyCmd.Flags().StringP("expiration", "e", "", "Human-readable expiration of the key (30m, 24h, 365d...)")
|
createPreAuthKeyCmd.PersistentFlags().
|
||||||
|
Bool("ephemeral", false, "Preauthkey for ephemeral nodes")
|
||||||
|
createPreAuthKeyCmd.Flags().
|
||||||
|
StringP("expiration", "e", DefaultPreAuthKeyExpiry, "Human-readable expiration of the key (e.g. 30m, 24h)")
|
||||||
|
createPreAuthKeyCmd.Flags().
|
||||||
|
StringSlice("tags", []string{}, "Tags to automatically assign to node")
|
||||||
}
|
}
|
||||||
|
|
||||||
var preauthkeysCmd = &cobra.Command{
|
var preauthkeysCmd = &cobra.Command{
|
||||||
Use: "preauthkeys",
|
Use: "preauthkeys",
|
||||||
Short: "Handle the preauthkeys in Headscale",
|
Short: "Handle the preauthkeys in Headscale",
|
||||||
|
Aliases: []string{"preauthkey", "authkey", "pre"},
|
||||||
}
|
}
|
||||||
|
|
||||||
var listPreAuthKeys = &cobra.Command{
|
var listPreAuthKeys = &cobra.Command{
|
||||||
Use: "list",
|
Use: "list",
|
||||||
Short: "List the preauthkeys for this namespace",
|
Short: "List the preauthkeys for this user",
|
||||||
|
Aliases: []string{"ls", "show"},
|
||||||
Run: func(cmd *cobra.Command, args []string) {
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
n, err := cmd.Flags().GetString("namespace")
|
output, _ := cmd.Flags().GetString("output")
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("Error getting namespace: %s", err)
|
|
||||||
}
|
|
||||||
o, _ := cmd.Flags().GetString("output")
|
|
||||||
|
|
||||||
h, err := getHeadscaleApp()
|
user, err := cmd.Flags().GetString("user")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("Error initializing: %s", err)
|
ErrorOutput(err, fmt.Sprintf("Error getting user: %s", err), output)
|
||||||
}
|
|
||||||
keys, err := h.GetPreAuthKeys(n)
|
|
||||||
if strings.HasPrefix(o, "json") {
|
|
||||||
JsonOutput(keys, err, o)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ctx, client, conn, cancel := getHeadscaleCLIClient()
|
||||||
|
defer cancel()
|
||||||
|
defer conn.Close()
|
||||||
|
|
||||||
|
request := &v1.ListPreAuthKeysRequest{
|
||||||
|
User: user,
|
||||||
|
}
|
||||||
|
|
||||||
|
response, err := client.ListPreAuthKeys(ctx, request)
|
||||||
if err != nil {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
d := pterm.TableData{{"ID", "Key", "Reusable", "Ephemeral", "Used", "Expiration", "Created"}}
|
if output != "" {
|
||||||
for _, k := range *keys {
|
SuccessOutput(response.PreAuthKeys, "", output)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
tableData := pterm.TableData{
|
||||||
|
{
|
||||||
|
"ID",
|
||||||
|
"Key",
|
||||||
|
"Reusable",
|
||||||
|
"Ephemeral",
|
||||||
|
"Used",
|
||||||
|
"Expiration",
|
||||||
|
"Created",
|
||||||
|
"Tags",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, key := range response.PreAuthKeys {
|
||||||
expiration := "-"
|
expiration := "-"
|
||||||
if k.Expiration != nil {
|
if key.GetExpiration() != nil {
|
||||||
expiration = k.Expiration.Format("2006-01-02 15:04:05")
|
expiration = ColourTime(key.Expiration.AsTime())
|
||||||
}
|
}
|
||||||
|
|
||||||
var reusable string
|
var reusable string
|
||||||
if k.Ephemeral {
|
if key.GetEphemeral() {
|
||||||
reusable = "N/A"
|
reusable = "N/A"
|
||||||
} else {
|
} else {
|
||||||
reusable = fmt.Sprintf("%v", k.Reusable)
|
reusable = fmt.Sprintf("%v", key.GetReusable())
|
||||||
}
|
}
|
||||||
|
|
||||||
d = append(d, []string{
|
aclTags := ""
|
||||||
strconv.FormatUint(k.ID, 10),
|
|
||||||
k.Key,
|
for _, tag := range key.AclTags {
|
||||||
|
aclTags += "," + tag
|
||||||
|
}
|
||||||
|
|
||||||
|
aclTags = strings.TrimLeft(aclTags, ",")
|
||||||
|
|
||||||
|
tableData = append(tableData, []string{
|
||||||
|
key.GetId(),
|
||||||
|
key.GetKey(),
|
||||||
reusable,
|
reusable,
|
||||||
strconv.FormatBool(k.Ephemeral),
|
strconv.FormatBool(key.GetEphemeral()),
|
||||||
fmt.Sprintf("%v", k.Used),
|
strconv.FormatBool(key.GetUsed()),
|
||||||
expiration,
|
expiration,
|
||||||
k.CreatedAt.Format("2006-01-02 15:04:05"),
|
key.GetCreatedAt().AsTime().Format("2006-01-02 15:04:05"),
|
||||||
|
aclTags,
|
||||||
})
|
})
|
||||||
|
|
||||||
}
|
}
|
||||||
err = pterm.DefaultTable.WithHasHeader().WithData(d).Render()
|
err = pterm.DefaultTable.WithHasHeader().WithData(tableData).Render()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal(err)
|
ErrorOutput(
|
||||||
|
err,
|
||||||
|
fmt.Sprintf("Failed to render pterm table: %s", err),
|
||||||
|
output,
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
var createPreAuthKeyCmd = &cobra.Command{
|
var createPreAuthKeyCmd = &cobra.Command{
|
||||||
Use: "create",
|
Use: "create",
|
||||||
Short: "Creates a new preauthkey in the specified namespace",
|
Short: "Creates a new preauthkey in the specified user",
|
||||||
|
Aliases: []string{"c", "new"},
|
||||||
Run: func(cmd *cobra.Command, args []string) {
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
n, err := cmd.Flags().GetString("namespace")
|
output, _ := cmd.Flags().GetString("output")
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("Error getting namespace: %s", err)
|
|
||||||
}
|
|
||||||
o, _ := cmd.Flags().GetString("output")
|
|
||||||
|
|
||||||
h, err := getHeadscaleApp()
|
user, err := cmd.Flags().GetString("user")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("Error initializing: %s", err)
|
ErrorOutput(err, fmt.Sprintf("Error getting user: %s", err), output)
|
||||||
|
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
reusable, _ := cmd.Flags().GetBool("reusable")
|
reusable, _ := cmd.Flags().GetBool("reusable")
|
||||||
ephemeral, _ := cmd.Flags().GetBool("ephemeral")
|
ephemeral, _ := cmd.Flags().GetBool("ephemeral")
|
||||||
|
tags, _ := cmd.Flags().GetStringSlice("tags")
|
||||||
|
|
||||||
e, _ := cmd.Flags().GetString("expiration")
|
log.Trace().
|
||||||
var expiration *time.Time
|
Bool("reusable", reusable).
|
||||||
if e != "" {
|
Bool("ephemeral", ephemeral).
|
||||||
duration, err := durafmt.ParseStringShort(e)
|
Str("user", user).
|
||||||
if err != nil {
|
Msg("Preparing to create preauthkey")
|
||||||
log.Fatalf("Error parsing expiration: %s", err)
|
|
||||||
}
|
request := &v1.CreatePreAuthKeyRequest{
|
||||||
exp := time.Now().UTC().Add(duration.Duration())
|
User: user,
|
||||||
expiration = &exp
|
Reusable: reusable,
|
||||||
|
Ephemeral: ephemeral,
|
||||||
|
AclTags: tags,
|
||||||
}
|
}
|
||||||
|
|
||||||
k, err := h.CreatePreAuthKey(n, reusable, ephemeral, expiration)
|
durationStr, _ := cmd.Flags().GetString("expiration")
|
||||||
if strings.HasPrefix(o, "json") {
|
|
||||||
JsonOutput(k, err, o)
|
duration, err := model.ParseDuration(durationStr)
|
||||||
return
|
|
||||||
}
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Println(err)
|
ErrorOutput(
|
||||||
|
err,
|
||||||
|
fmt.Sprintf("Could not parse duration: %s\n", err),
|
||||||
|
output,
|
||||||
|
)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
fmt.Printf("%s\n", k.Key)
|
|
||||||
|
expiration := time.Now().UTC().Add(time.Duration(duration))
|
||||||
|
|
||||||
|
log.Trace().
|
||||||
|
Dur("expiration", time.Duration(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 {
|
||||||
|
ErrorOutput(
|
||||||
|
err,
|
||||||
|
fmt.Sprintf("Cannot create Pre Auth Key: %s\n", err),
|
||||||
|
output,
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
SuccessOutput(response.PreAuthKey, response.PreAuthKey.Key, output)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
var expirePreAuthKeyCmd = &cobra.Command{
|
var expirePreAuthKeyCmd = &cobra.Command{
|
||||||
Use: "expire KEY",
|
Use: "expire KEY",
|
||||||
Short: "Expire a preauthkey",
|
Short: "Expire a preauthkey",
|
||||||
|
Aliases: []string{"revoke", "exp", "e"},
|
||||||
Args: func(cmd *cobra.Command, args []string) error {
|
Args: func(cmd *cobra.Command, args []string) error {
|
||||||
if len(args) < 1 {
|
if len(args) < 1 {
|
||||||
return fmt.Errorf("missing parameters")
|
return errMissingParameter
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
Run: func(cmd *cobra.Command, args []string) {
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
n, err := cmd.Flags().GetString("namespace")
|
output, _ := cmd.Flags().GetString("output")
|
||||||
|
user, err := cmd.Flags().GetString("user")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("Error getting namespace: %s", err)
|
ErrorOutput(err, fmt.Sprintf("Error getting user: %s", err), output)
|
||||||
}
|
|
||||||
o, _ := cmd.Flags().GetString("output")
|
|
||||||
|
|
||||||
h, err := getHeadscaleApp()
|
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("Error initializing: %s", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
k, err := h.GetPreAuthKey(n, args[0])
|
|
||||||
if err != nil {
|
|
||||||
if strings.HasPrefix(o, "json") {
|
|
||||||
JsonOutput(k, err, o)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
log.Fatalf("Error getting the key: %s", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
err = h.MarkExpirePreAuthKey(k)
|
|
||||||
if strings.HasPrefix(o, "json") {
|
|
||||||
JsonOutput(k, err, o)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ctx, client, conn, cancel := getHeadscaleCLIClient()
|
||||||
|
defer cancel()
|
||||||
|
defer conn.Close()
|
||||||
|
|
||||||
|
request := &v1.ExpirePreAuthKeyRequest{
|
||||||
|
User: user,
|
||||||
|
Key: args[0],
|
||||||
|
}
|
||||||
|
|
||||||
|
response, err := client.ExpirePreAuthKey(ctx, request)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Println(err)
|
ErrorOutput(
|
||||||
|
err,
|
||||||
|
fmt.Sprintf("Cannot expire Pre Auth Key: %s\n", err),
|
||||||
|
output,
|
||||||
|
)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
fmt.Println("Expired")
|
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -3,13 +3,89 @@ package cli
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
"runtime"
|
||||||
|
|
||||||
|
"github.com/juanfont/headscale"
|
||||||
|
"github.com/rs/zerolog"
|
||||||
|
"github.com/rs/zerolog/log"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
"github.com/tcnksm/go-latest"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
deprecateNamespaceMessage = "use --user"
|
||||||
|
)
|
||||||
|
|
||||||
|
var cfgFile string = ""
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
rootCmd.PersistentFlags().StringP("output", "o", "", "Output format. Empty for human-readable, 'json' or 'json-line'")
|
if len(os.Args) > 1 &&
|
||||||
rootCmd.PersistentFlags().Bool("force", false, "Disable prompts and forces the execution")
|
(os.Args[1] == "version" || os.Args[1] == "mockoidc" || os.Args[1] == "completion") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
cobra.OnInitialize(initConfig)
|
||||||
|
rootCmd.PersistentFlags().
|
||||||
|
StringVarP(&cfgFile, "config", "c", "", "config file (default is /etc/headscale/config.yaml)")
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
|
||||||
|
func initConfig() {
|
||||||
|
if cfgFile == "" {
|
||||||
|
cfgFile = os.Getenv("HEADSCALE_CONFIG")
|
||||||
|
}
|
||||||
|
if cfgFile != "" {
|
||||||
|
err := headscale.LoadConfig(cfgFile, true)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal().Caller().Err(err).Msgf("Error loading config file %s", cfgFile)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
err := headscale.LoadConfig("", false)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal().Caller().Err(err).Msgf("Error loading config")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err := headscale.GetHeadscaleConfig()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal().Caller().Err(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
machineOutput := HasMachineOutputFlag()
|
||||||
|
|
||||||
|
zerolog.SetGlobalLevel(cfg.Log.Level)
|
||||||
|
|
||||||
|
// If the user has requested a "machine" readable format,
|
||||||
|
// then disable login so the output remains valid.
|
||||||
|
if machineOutput {
|
||||||
|
zerolog.SetGlobalLevel(zerolog.Disabled)
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.Log.Format == headscale.JSONLogFormat {
|
||||||
|
log.Logger = log.Output(os.Stdout)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !cfg.DisableUpdateCheck && !machineOutput {
|
||||||
|
if (runtime.GOOS == "linux" || runtime.GOOS == "darwin") &&
|
||||||
|
Version != "dev" {
|
||||||
|
githubTag := &latest.GithubTag{
|
||||||
|
Owner: "juanfont",
|
||||||
|
Repository: "headscale",
|
||||||
|
}
|
||||||
|
res, err := latest.Check(githubTag, 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,
|
||||||
|
Version,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var rootCmd = &cobra.Command{
|
var rootCmd = &cobra.Command{
|
||||||
|
|||||||
@@ -3,145 +3,296 @@ package cli
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"strings"
|
"net/netip"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/juanfont/headscale"
|
||||||
|
v1 "github.com/juanfont/headscale/gen/go/headscale/v1"
|
||||||
"github.com/pterm/pterm"
|
"github.com/pterm/pterm"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
"google.golang.org/grpc/status"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
Base10 = 10
|
||||||
)
|
)
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
rootCmd.AddCommand(routesCmd)
|
rootCmd.AddCommand(routesCmd)
|
||||||
routesCmd.PersistentFlags().StringP("namespace", "n", "", "Namespace")
|
listRoutesCmd.Flags().Uint64P("identifier", "i", 0, "Node identifier (ID)")
|
||||||
err := routesCmd.MarkPersistentFlagRequired("namespace")
|
routesCmd.AddCommand(listRoutesCmd)
|
||||||
|
|
||||||
|
enableRouteCmd.Flags().Uint64P("route", "r", 0, "Route identifier (ID)")
|
||||||
|
err := enableRouteCmd.MarkFlagRequired("route")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf(err.Error())
|
log.Fatalf(err.Error())
|
||||||
}
|
}
|
||||||
|
|
||||||
enableRouteCmd.Flags().BoolP("all", "a", false, "Enable all routes advertised by the node")
|
|
||||||
|
|
||||||
routesCmd.AddCommand(listRoutesCmd)
|
|
||||||
routesCmd.AddCommand(enableRouteCmd)
|
routesCmd.AddCommand(enableRouteCmd)
|
||||||
|
|
||||||
|
disableRouteCmd.Flags().Uint64P("route", "r", 0, "Route identifier (ID)")
|
||||||
|
err = disableRouteCmd.MarkFlagRequired("route")
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf(err.Error())
|
||||||
|
}
|
||||||
|
routesCmd.AddCommand(disableRouteCmd)
|
||||||
|
|
||||||
|
deleteRouteCmd.Flags().Uint64P("route", "r", 0, "Route identifier (ID)")
|
||||||
|
err = deleteRouteCmd.MarkFlagRequired("route")
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf(err.Error())
|
||||||
|
}
|
||||||
|
routesCmd.AddCommand(deleteRouteCmd)
|
||||||
}
|
}
|
||||||
|
|
||||||
var routesCmd = &cobra.Command{
|
var routesCmd = &cobra.Command{
|
||||||
Use: "routes",
|
Use: "routes",
|
||||||
Short: "Manage the routes of Headscale",
|
Short: "Manage the routes of Headscale",
|
||||||
|
Aliases: []string{"r", "route"},
|
||||||
}
|
}
|
||||||
|
|
||||||
var listRoutesCmd = &cobra.Command{
|
var listRoutesCmd = &cobra.Command{
|
||||||
Use: "list NODE",
|
Use: "list",
|
||||||
Short: "List the routes exposed by this node",
|
Short: "List all routes",
|
||||||
Args: func(cmd *cobra.Command, args []string) error {
|
Aliases: []string{"ls", "show"},
|
||||||
if len(args) < 1 {
|
|
||||||
return fmt.Errorf("Missing parameters")
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
},
|
|
||||||
Run: func(cmd *cobra.Command, args []string) {
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
n, err := cmd.Flags().GetString("namespace")
|
output, _ := cmd.Flags().GetString("output")
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("Error getting namespace: %s", err)
|
|
||||||
}
|
|
||||||
o, _ := cmd.Flags().GetString("output")
|
|
||||||
|
|
||||||
h, err := getHeadscaleApp()
|
machineID, err := cmd.Flags().GetUint64("identifier")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("Error initializing: %s", err)
|
ErrorOutput(
|
||||||
}
|
err,
|
||||||
|
fmt.Sprintf("Error getting machine id from flag: %s", err),
|
||||||
|
output,
|
||||||
|
)
|
||||||
|
|
||||||
availableRoutes, err := h.GetAdvertisedNodeRoutes(n, args[0])
|
|
||||||
if err != nil {
|
|
||||||
fmt.Println(err)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if strings.HasPrefix(o, "json") {
|
ctx, client, conn, cancel := getHeadscaleCLIClient()
|
||||||
// TODO: Add enable/disabled information to this interface
|
defer cancel()
|
||||||
JsonOutput(availableRoutes, err, o)
|
defer conn.Close()
|
||||||
|
|
||||||
|
var routes []*v1.Route
|
||||||
|
|
||||||
|
if machineID == 0 {
|
||||||
|
response, err := client.GetRoutes(ctx, &v1.GetRoutesRequest{})
|
||||||
|
if err != nil {
|
||||||
|
ErrorOutput(
|
||||||
|
err,
|
||||||
|
fmt.Sprintf("Cannot get nodes: %s", status.Convert(err).Message()),
|
||||||
|
output,
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if output != "" {
|
||||||
|
SuccessOutput(response.Routes, "", output)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
routes = response.Routes
|
||||||
|
} else {
|
||||||
|
response, err := client.GetMachineRoutes(ctx, &v1.GetMachineRoutesRequest{
|
||||||
|
MachineId: machineID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
ErrorOutput(
|
||||||
|
err,
|
||||||
|
fmt.Sprintf("Cannot get routes for machine %d: %s", machineID, status.Convert(err).Message()),
|
||||||
|
output,
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if output != "" {
|
||||||
|
SuccessOutput(response.Routes, "", output)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
routes = response.Routes
|
||||||
|
}
|
||||||
|
|
||||||
|
tableData := routesToPtables(routes)
|
||||||
|
if err != nil {
|
||||||
|
ErrorOutput(err, fmt.Sprintf("Error converting to table: %s", err), output)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
d := h.RoutesToPtables(n, args[0], *availableRoutes)
|
err = pterm.DefaultTable.WithHasHeader().WithData(tableData).Render()
|
||||||
|
|
||||||
err = pterm.DefaultTable.WithHasHeader().WithData(d).Render()
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal(err)
|
ErrorOutput(
|
||||||
|
err,
|
||||||
|
fmt.Sprintf("Failed to render pterm table: %s", err),
|
||||||
|
output,
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
var enableRouteCmd = &cobra.Command{
|
var enableRouteCmd = &cobra.Command{
|
||||||
Use: "enable node-name route",
|
Use: "enable",
|
||||||
Short: "Allows exposing a route declared by this node to the rest of the nodes",
|
Short: "Set a route as enabled",
|
||||||
Args: func(cmd *cobra.Command, args []string) error {
|
Long: `This command will make as enabled a given route.`,
|
||||||
all, err := cmd.Flags().GetBool("all")
|
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("Error getting namespace: %s", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if all {
|
|
||||||
if len(args) < 1 {
|
|
||||||
return fmt.Errorf("Missing parameters")
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
} else {
|
|
||||||
if len(args) < 2 {
|
|
||||||
return fmt.Errorf("Missing parameters")
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
},
|
|
||||||
Run: func(cmd *cobra.Command, args []string) {
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
n, err := cmd.Flags().GetString("namespace")
|
output, _ := cmd.Flags().GetString("output")
|
||||||
|
|
||||||
|
routeID, err := cmd.Flags().GetUint64("route")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("Error getting namespace: %s", err)
|
ErrorOutput(
|
||||||
|
err,
|
||||||
|
fmt.Sprintf("Error getting machine id from flag: %s", err),
|
||||||
|
output,
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
o, _ := cmd.Flags().GetString("output")
|
ctx, client, conn, cancel := getHeadscaleCLIClient()
|
||||||
|
defer cancel()
|
||||||
|
defer conn.Close()
|
||||||
|
|
||||||
all, err := cmd.Flags().GetBool("all")
|
response, err := client.EnableRoute(ctx, &v1.EnableRouteRequest{
|
||||||
|
RouteId: routeID,
|
||||||
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("Error getting namespace: %s", err)
|
ErrorOutput(
|
||||||
|
err,
|
||||||
|
fmt.Sprintf("Cannot enable route %d: %s", routeID, status.Convert(err).Message()),
|
||||||
|
output,
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
h, err := getHeadscaleApp()
|
if output != "" {
|
||||||
if err != nil {
|
SuccessOutput(response, "", output)
|
||||||
log.Fatalf("Error initializing: %s", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if all {
|
return
|
||||||
availableRoutes, err := h.GetAdvertisedNodeRoutes(n, args[0])
|
|
||||||
if err != nil {
|
|
||||||
fmt.Println(err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, availableRoute := range *availableRoutes {
|
|
||||||
err = h.EnableNodeRoute(n, args[0], availableRoute.String())
|
|
||||||
if err != nil {
|
|
||||||
fmt.Println(err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if strings.HasPrefix(o, "json") {
|
|
||||||
JsonOutput(availableRoute, err, o)
|
|
||||||
} else {
|
|
||||||
fmt.Printf("Enabled route %s\n", availableRoute)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
err = h.EnableNodeRoute(n, args[0], args[1])
|
|
||||||
|
|
||||||
if strings.HasPrefix(o, "json") {
|
|
||||||
JsonOutput(args[1], err, o)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
fmt.Println(err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
fmt.Printf("Enabled route %s\n", args[1])
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var disableRouteCmd = &cobra.Command{
|
||||||
|
Use: "disable",
|
||||||
|
Short: "Set as disabled a given route",
|
||||||
|
Long: `This command will make as disabled a given route.`,
|
||||||
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
|
output, _ := cmd.Flags().GetString("output")
|
||||||
|
|
||||||
|
routeID, err := cmd.Flags().GetUint64("route")
|
||||||
|
if err != nil {
|
||||||
|
ErrorOutput(
|
||||||
|
err,
|
||||||
|
fmt.Sprintf("Error getting machine id from flag: %s", err),
|
||||||
|
output,
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, client, conn, cancel := getHeadscaleCLIClient()
|
||||||
|
defer cancel()
|
||||||
|
defer conn.Close()
|
||||||
|
|
||||||
|
response, err := client.DisableRoute(ctx, &v1.DisableRouteRequest{
|
||||||
|
RouteId: routeID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
ErrorOutput(
|
||||||
|
err,
|
||||||
|
fmt.Sprintf("Cannot disable route %d: %s", routeID, status.Convert(err).Message()),
|
||||||
|
output,
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if output != "" {
|
||||||
|
SuccessOutput(response, "", output)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var deleteRouteCmd = &cobra.Command{
|
||||||
|
Use: "delete",
|
||||||
|
Short: "Delete a given route",
|
||||||
|
Long: `This command will delete a given route.`,
|
||||||
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
|
output, _ := cmd.Flags().GetString("output")
|
||||||
|
|
||||||
|
routeID, err := cmd.Flags().GetUint64("route")
|
||||||
|
if err != nil {
|
||||||
|
ErrorOutput(
|
||||||
|
err,
|
||||||
|
fmt.Sprintf("Error getting machine id from flag: %s", err),
|
||||||
|
output,
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, client, conn, cancel := getHeadscaleCLIClient()
|
||||||
|
defer cancel()
|
||||||
|
defer conn.Close()
|
||||||
|
|
||||||
|
response, err := client.DeleteRoute(ctx, &v1.DeleteRouteRequest{
|
||||||
|
RouteId: routeID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
ErrorOutput(
|
||||||
|
err,
|
||||||
|
fmt.Sprintf("Cannot delete route %d: %s", routeID, status.Convert(err).Message()),
|
||||||
|
output,
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if output != "" {
|
||||||
|
SuccessOutput(response, "", output)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// routesToPtables converts the list of routes to a nice table.
|
||||||
|
func routesToPtables(routes []*v1.Route) pterm.TableData {
|
||||||
|
tableData := pterm.TableData{{"ID", "Machine", "Prefix", "Advertised", "Enabled", "Primary"}}
|
||||||
|
|
||||||
|
for _, route := range routes {
|
||||||
|
var isPrimaryStr string
|
||||||
|
prefix, err := netip.ParsePrefix(route.Prefix)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Error parsing prefix %s: %s", route.Prefix, err)
|
||||||
|
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if prefix == headscale.ExitRouteV4 || prefix == headscale.ExitRouteV6 {
|
||||||
|
isPrimaryStr = "-"
|
||||||
|
} else {
|
||||||
|
isPrimaryStr = strconv.FormatBool(route.IsPrimary)
|
||||||
|
}
|
||||||
|
|
||||||
|
tableData = append(tableData,
|
||||||
|
[]string{
|
||||||
|
strconv.FormatUint(route.Id, Base10),
|
||||||
|
route.Machine.GivenName,
|
||||||
|
route.Prefix,
|
||||||
|
strconv.FormatBool(route.Advertised),
|
||||||
|
strconv.FormatBool(route.Enabled),
|
||||||
|
isPrimaryStr,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return tableData
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
package cli
|
package cli
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"log"
|
"github.com/rs/zerolog/log"
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -17,14 +16,14 @@ var serveCmd = &cobra.Command{
|
|||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
Run: func(cmd *cobra.Command, args []string) {
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
h, err := getHeadscaleApp()
|
app, err := getHeadscaleApp()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("Error initializing: %s", err)
|
log.Fatal().Caller().Err(err).Msg("Error initializing")
|
||||||
}
|
}
|
||||||
|
|
||||||
err = h.Serve()
|
err = app.Serve()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("Error initializing: %s", err)
|
log.Fatal().Caller().Err(err).Msg("Error starting server")
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
243
cmd/headscale/cli/users.go
Normal file
243
cmd/headscale/cli/users.go
Normal file
@@ -0,0 +1,243 @@
|
|||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
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"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
rootCmd.AddCommand(userCmd)
|
||||||
|
userCmd.AddCommand(createUserCmd)
|
||||||
|
userCmd.AddCommand(listUsersCmd)
|
||||||
|
userCmd.AddCommand(destroyUserCmd)
|
||||||
|
userCmd.AddCommand(renameUserCmd)
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
errMissingParameter = headscale.Error("missing parameters")
|
||||||
|
)
|
||||||
|
|
||||||
|
var userCmd = &cobra.Command{
|
||||||
|
Use: "users",
|
||||||
|
Short: "Manage the users of Headscale",
|
||||||
|
Aliases: []string{"user", "namespace", "namespaces", "ns"},
|
||||||
|
}
|
||||||
|
|
||||||
|
var createUserCmd = &cobra.Command{
|
||||||
|
Use: "create NAME",
|
||||||
|
Short: "Creates a new user",
|
||||||
|
Aliases: []string{"c", "new"},
|
||||||
|
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")
|
||||||
|
|
||||||
|
userName := args[0]
|
||||||
|
|
||||||
|
ctx, client, conn, cancel := getHeadscaleCLIClient()
|
||||||
|
defer cancel()
|
||||||
|
defer conn.Close()
|
||||||
|
|
||||||
|
log.Trace().Interface("client", client).Msg("Obtained gRPC client")
|
||||||
|
|
||||||
|
request := &v1.CreateUserRequest{Name: userName}
|
||||||
|
|
||||||
|
log.Trace().Interface("request", request).Msg("Sending CreateUser request")
|
||||||
|
response, err := client.CreateUser(ctx, request)
|
||||||
|
if err != nil {
|
||||||
|
ErrorOutput(
|
||||||
|
err,
|
||||||
|
fmt.Sprintf(
|
||||||
|
"Cannot create user: %s",
|
||||||
|
status.Convert(err).Message(),
|
||||||
|
),
|
||||||
|
output,
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
SuccessOutput(response.User, "User created", output)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var destroyUserCmd = &cobra.Command{
|
||||||
|
Use: "destroy NAME",
|
||||||
|
Short: "Destroys a user",
|
||||||
|
Aliases: []string{"delete"},
|
||||||
|
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")
|
||||||
|
|
||||||
|
userName := args[0]
|
||||||
|
|
||||||
|
request := &v1.GetUserRequest{
|
||||||
|
Name: userName,
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, client, conn, cancel := getHeadscaleCLIClient()
|
||||||
|
defer cancel()
|
||||||
|
defer conn.Close()
|
||||||
|
|
||||||
|
_, err := client.GetUser(ctx, request)
|
||||||
|
if err != nil {
|
||||||
|
ErrorOutput(
|
||||||
|
err,
|
||||||
|
fmt.Sprintf("Error: %s", status.Convert(err).Message()),
|
||||||
|
output,
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
confirm := false
|
||||||
|
force, _ := cmd.Flags().GetBool("force")
|
||||||
|
if !force {
|
||||||
|
prompt := &survey.Confirm{
|
||||||
|
Message: fmt.Sprintf(
|
||||||
|
"Do you want to remove the user '%s' and any associated preauthkeys?",
|
||||||
|
userName,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
err := survey.AskOne(prompt, &confirm)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if confirm || force {
|
||||||
|
request := &v1.DeleteUserRequest{Name: userName}
|
||||||
|
|
||||||
|
response, err := client.DeleteUser(ctx, request)
|
||||||
|
if err != nil {
|
||||||
|
ErrorOutput(
|
||||||
|
err,
|
||||||
|
fmt.Sprintf(
|
||||||
|
"Cannot destroy user: %s",
|
||||||
|
status.Convert(err).Message(),
|
||||||
|
),
|
||||||
|
output,
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
SuccessOutput(response, "User destroyed", output)
|
||||||
|
} else {
|
||||||
|
SuccessOutput(map[string]string{"Result": "User not destroyed"}, "User not destroyed", output)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var listUsersCmd = &cobra.Command{
|
||||||
|
Use: "list",
|
||||||
|
Short: "List all the users",
|
||||||
|
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.ListUsersRequest{}
|
||||||
|
|
||||||
|
response, err := client.ListUsers(ctx, request)
|
||||||
|
if err != nil {
|
||||||
|
ErrorOutput(
|
||||||
|
err,
|
||||||
|
fmt.Sprintf("Cannot get users: %s", status.Convert(err).Message()),
|
||||||
|
output,
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if output != "" {
|
||||||
|
SuccessOutput(response.Users, "", output)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
tableData := pterm.TableData{{"ID", "Name", "Created"}}
|
||||||
|
for _, user := range response.GetUsers() {
|
||||||
|
tableData = append(
|
||||||
|
tableData,
|
||||||
|
[]string{
|
||||||
|
user.GetId(),
|
||||||
|
user.GetName(),
|
||||||
|
user.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 renameUserCmd = &cobra.Command{
|
||||||
|
Use: "rename OLD_NAME NEW_NAME",
|
||||||
|
Short: "Renames a user",
|
||||||
|
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.RenameUserRequest{
|
||||||
|
OldName: args[0],
|
||||||
|
NewName: args[1],
|
||||||
|
}
|
||||||
|
|
||||||
|
response, err := client.RenameUser(ctx, request)
|
||||||
|
if err != nil {
|
||||||
|
ErrorOutput(
|
||||||
|
err,
|
||||||
|
fmt.Sprintf(
|
||||||
|
"Cannot rename user: %s",
|
||||||
|
status.Convert(err).Message(),
|
||||||
|
),
|
||||||
|
output,
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
SuccessOutput(response.User, "User renamed", output)
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -1,300 +1,219 @@
|
|||||||
package cli
|
package cli
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/tls"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"reflect"
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/juanfont/headscale"
|
"github.com/juanfont/headscale"
|
||||||
|
v1 "github.com/juanfont/headscale/gen/go/headscale/v1"
|
||||||
"github.com/rs/zerolog/log"
|
"github.com/rs/zerolog/log"
|
||||||
"github.com/spf13/viper"
|
"google.golang.org/grpc"
|
||||||
"gopkg.in/yaml.v2"
|
"google.golang.org/grpc/credentials"
|
||||||
"inet.af/netaddr"
|
"google.golang.org/grpc/credentials/insecure"
|
||||||
"tailscale.com/tailcfg"
|
"gopkg.in/yaml.v3"
|
||||||
"tailscale.com/types/dnstype"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type ErrorOutput struct {
|
const (
|
||||||
Error string
|
HeadscaleDateTimeFormat = "2006-01-02 15:04:05"
|
||||||
}
|
SocketWritePermissions = 0o666
|
||||||
|
)
|
||||||
func LoadConfig(path string) error {
|
|
||||||
viper.SetConfigName("config")
|
|
||||||
if path == "" {
|
|
||||||
viper.AddConfigPath("/etc/headscale/")
|
|
||||||
viper.AddConfigPath("$HOME/.headscale")
|
|
||||||
viper.AddConfigPath(".")
|
|
||||||
} else {
|
|
||||||
// For testing
|
|
||||||
viper.AddConfigPath(path)
|
|
||||||
}
|
|
||||||
viper.AutomaticEnv()
|
|
||||||
|
|
||||||
viper.SetDefault("tls_letsencrypt_cache_dir", "/var/www/.cache")
|
|
||||||
viper.SetDefault("tls_letsencrypt_challenge_type", "HTTP-01")
|
|
||||||
|
|
||||||
viper.SetDefault("ip_prefix", "100.64.0.0/10")
|
|
||||||
|
|
||||||
viper.SetDefault("log_level", "info")
|
|
||||||
|
|
||||||
viper.SetDefault("dns_config", nil)
|
|
||||||
|
|
||||||
err := viper.ReadInConfig()
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("Fatal error reading config file: %s \n", 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") != "")) {
|
|
||||||
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")) {
|
|
||||||
// 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") {
|
|
||||||
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://") {
|
|
||||||
errorText += "Fatal config error: server_url must start with https:// or http://\n"
|
|
||||||
}
|
|
||||||
if errorText != "" {
|
|
||||||
return errors.New(strings.TrimSuffix(errorText, "\n"))
|
|
||||||
} else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
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.
|
|
||||||
if (path != "") && !strings.HasPrefix(path, string(os.PathSeparator)) {
|
|
||||||
dir, _ := filepath.Split(viper.ConfigFileUsed())
|
|
||||||
if dir != "" {
|
|
||||||
path = filepath.Join(dir, path)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return path
|
|
||||||
}
|
|
||||||
|
|
||||||
func getHeadscaleApp() (*headscale.Headscale, error) {
|
func getHeadscaleApp() (*headscale.Headscale, error) {
|
||||||
derpPath := absPath(viper.GetString("derp_map_path"))
|
cfg, err := headscale.GetHeadscaleConfig()
|
||||||
derpMap, err := loadDerpMap(derpPath)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error().
|
return nil, fmt.Errorf(
|
||||||
Str("path", derpPath).
|
"failed to load configuration while creating headscale instance: %w",
|
||||||
Err(err).
|
err,
|
||||||
Msg("Could not load DERP servers map file")
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Minimum inactivity time out is keepalive timeout (60s) plus a few seconds
|
app, err := headscale.NewHeadscale(cfg)
|
||||||
// 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
|
|
||||||
}
|
|
||||||
|
|
||||||
dnsConfig, baseDomain := GetDNSConfig()
|
|
||||||
|
|
||||||
cfg := headscale.Config{
|
|
||||||
ServerURL: viper.GetString("server_url"),
|
|
||||||
Addr: viper.GetString("listen_addr"),
|
|
||||||
PrivateKeyPath: absPath(viper.GetString("private_key_path")),
|
|
||||||
DerpMap: derpMap,
|
|
||||||
IPPrefix: netaddr.MustParseIPPrefix(viper.GetString("ip_prefix")),
|
|
||||||
BaseDomain: baseDomain,
|
|
||||||
|
|
||||||
EphemeralNodeInactivityTimeout: viper.GetDuration("ephemeral_node_inactivity_timeout"),
|
|
||||||
|
|
||||||
DBtype: viper.GetString("db_type"),
|
|
||||||
DBpath: absPath(viper.GetString("db_path")),
|
|
||||||
DBhost: viper.GetString("db_host"),
|
|
||||||
DBport: viper.GetInt("db_port"),
|
|
||||||
DBname: viper.GetString("db_name"),
|
|
||||||
DBuser: viper.GetString("db_user"),
|
|
||||||
DBpass: viper.GetString("db_pass"),
|
|
||||||
|
|
||||||
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")),
|
|
||||||
|
|
||||||
DNSConfig: dnsConfig,
|
|
||||||
|
|
||||||
ACMEEmail: viper.GetString("acme_email"),
|
|
||||||
ACMEURL: viper.GetString("acme_url"),
|
|
||||||
}
|
|
||||||
|
|
||||||
h, err := headscale.NewHeadscale(cfg)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// We are doing this here, as in the future could be cool to have it also hot-reload
|
// We are doing this here, as in the future could be cool to have it also hot-reload
|
||||||
|
|
||||||
if viper.GetString("acl_policy_path") != "" {
|
if cfg.ACL.PolicyPath != "" {
|
||||||
aclPath := absPath(viper.GetString("acl_policy_path"))
|
aclPath := headscale.AbsolutePathFromConfigPath(cfg.ACL.PolicyPath)
|
||||||
err = h.LoadACLPolicy(aclPath)
|
err = app.LoadACLPolicy(aclPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error().
|
log.Fatal().
|
||||||
Str("path", aclPath).
|
Str("path", aclPath).
|
||||||
Err(err).
|
Err(err).
|
||||||
Msg("Could not load the ACL policy")
|
Msg("Could not load the ACL policy")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return h, nil
|
return app, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func loadDerpMap(path string) (*tailcfg.DERPMap, error) {
|
func getHeadscaleCLIClient() (context.Context, v1.HeadscaleServiceClient, *grpc.ClientConn, context.CancelFunc) {
|
||||||
derpFile, err := os.Open(path)
|
cfg, err := headscale.GetHeadscaleConfig()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
log.Fatal().
|
||||||
|
Err(err).
|
||||||
|
Caller().
|
||||||
|
Msgf("Failed to load configuration")
|
||||||
|
os.Exit(-1) // we get here if logging is suppressed (i.e., json output)
|
||||||
}
|
}
|
||||||
defer derpFile.Close()
|
|
||||||
var derpMap tailcfg.DERPMap
|
log.Debug().
|
||||||
b, err := io.ReadAll(derpFile)
|
Dur("timeout", cfg.CLI.Timeout).
|
||||||
|
Msgf("Setting timeout")
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), cfg.CLI.Timeout)
|
||||||
|
|
||||||
|
grpcOptions := []grpc.DialOption{
|
||||||
|
grpc.WithBlock(),
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
// Try to give the user better feedback if we cannot write to the headscale
|
||||||
|
// socket.
|
||||||
|
socket, err := os.OpenFile(cfg.UnixSocket, os.O_WRONLY, SocketWritePermissions) //nolint
|
||||||
|
if err != nil {
|
||||||
|
if os.IsPermission(err) {
|
||||||
|
log.Fatal().
|
||||||
|
Err(err).
|
||||||
|
Str("socket", cfg.UnixSocket).
|
||||||
|
Msgf("Unable to read/write to headscale socket, do you have the correct permissions?")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
socket.Close()
|
||||||
|
|
||||||
|
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, "")),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Trace().Caller().Str("address", address).Msg("Connecting via gRPC")
|
||||||
|
conn, err := grpc.DialContext(ctx, address, grpcOptions...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
log.Fatal().Caller().Err(err).Msgf("Could not connect: %v", err)
|
||||||
|
os.Exit(-1) // we get here if logging is suppressed (i.e., json output)
|
||||||
}
|
}
|
||||||
err = yaml.Unmarshal(b, &derpMap)
|
|
||||||
return &derpMap, err
|
client := v1.NewHeadscaleServiceClient(conn)
|
||||||
|
|
||||||
|
return ctx, client, conn, cancel
|
||||||
}
|
}
|
||||||
|
|
||||||
func JsonOutput(result interface{}, errResult error, outputFormat string) {
|
func SuccessOutput(result interface{}, override string, outputFormat string) {
|
||||||
var j []byte
|
var jsonBytes []byte
|
||||||
var err error
|
var err error
|
||||||
switch outputFormat {
|
switch outputFormat {
|
||||||
case "json":
|
case "json":
|
||||||
if errResult != nil {
|
jsonBytes, err = json.MarshalIndent(result, "", "\t")
|
||||||
j, err = json.MarshalIndent(ErrorOutput{errResult.Error()}, "", "\t")
|
if err != nil {
|
||||||
if err != nil {
|
log.Fatal().Err(err)
|
||||||
log.Fatal().Err(err)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
j, err = json.MarshalIndent(result, "", "\t")
|
|
||||||
if err != nil {
|
|
||||||
log.Fatal().Err(err)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
case "json-line":
|
case "json-line":
|
||||||
if errResult != nil {
|
jsonBytes, err = json.Marshal(result)
|
||||||
j, err = json.Marshal(ErrorOutput{errResult.Error()})
|
if err != nil {
|
||||||
if err != nil {
|
log.Fatal().Err(err)
|
||||||
log.Fatal().Err(err)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
j, 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
|
||||||
}
|
}
|
||||||
fmt.Println(string(j))
|
|
||||||
|
//nolint
|
||||||
|
fmt.Println(string(jsonBytes))
|
||||||
}
|
}
|
||||||
|
|
||||||
func HasJsonOutputFlag() bool {
|
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 {
|
for _, arg := range os.Args {
|
||||||
if arg == "json" || arg == "json-line" {
|
if arg == "json" || arg == "json-line" || arg == "yaml" {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 contains[T string](ts []T, t T) bool {
|
||||||
|
for _, v := range ts {
|
||||||
|
if reflect.DeepEqual(v, t) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,6 @@
|
|||||||
package cli
|
package cli
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -18,11 +15,7 @@ var versionCmd = &cobra.Command{
|
|||||||
Short: "Print the version.",
|
Short: "Print the version.",
|
||||||
Long: "The version of headscale.",
|
Long: "The version of headscale.",
|
||||||
Run: func(cmd *cobra.Command, args []string) {
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
o, _ := cmd.Flags().GetString("output")
|
output, _ := cmd.Flags().GetString("output")
|
||||||
if strings.HasPrefix(o, "json") {
|
SuccessOutput(map[string]string{"version": Version}, Version, output)
|
||||||
JsonOutput(map[string]string{"version": Version}, nil, o)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
fmt.Println(Version)
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,13 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
|
||||||
"os"
|
"os"
|
||||||
"runtime"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/efekarakus/termcolor"
|
"github.com/efekarakus/termcolor"
|
||||||
"github.com/juanfont/headscale/cmd/headscale/cli"
|
"github.com/juanfont/headscale/cmd/headscale/cli"
|
||||||
"github.com/rs/zerolog"
|
"github.com/rs/zerolog"
|
||||||
"github.com/rs/zerolog/log"
|
"github.com/rs/zerolog/log"
|
||||||
"github.com/spf13/viper"
|
|
||||||
"github.com/tcnksm/go-latest"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
@@ -23,6 +19,8 @@ func main() {
|
|||||||
colors = true
|
colors = true
|
||||||
case termcolor.LevelBasic:
|
case termcolor.LevelBasic:
|
||||||
colors = true
|
colors = true
|
||||||
|
case termcolor.LevelNone:
|
||||||
|
colors = false
|
||||||
default:
|
default:
|
||||||
// no color, return text as is.
|
// no color, return text as is.
|
||||||
colors = false
|
colors = false
|
||||||
@@ -41,41 +39,5 @@ func main() {
|
|||||||
NoColor: !colors,
|
NoColor: !colors,
|
||||||
})
|
})
|
||||||
|
|
||||||
err := cli.LoadConfig("")
|
|
||||||
if err != nil {
|
|
||||||
log.Fatal().Err(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
logLevel := viper.GetString("log_level")
|
|
||||||
switch logLevel {
|
|
||||||
case "trace":
|
|
||||||
zerolog.SetGlobalLevel(zerolog.TraceLevel)
|
|
||||||
case "debug":
|
|
||||||
zerolog.SetGlobalLevel(zerolog.DebugLevel)
|
|
||||||
case "info":
|
|
||||||
zerolog.SetGlobalLevel(zerolog.InfoLevel)
|
|
||||||
case "warn":
|
|
||||||
zerolog.SetGlobalLevel(zerolog.WarnLevel)
|
|
||||||
case "error":
|
|
||||||
zerolog.SetGlobalLevel(zerolog.ErrorLevel)
|
|
||||||
default:
|
|
||||||
zerolog.SetGlobalLevel(zerolog.DebugLevel)
|
|
||||||
}
|
|
||||||
|
|
||||||
jsonOutput := cli.HasJsonOutputFlag()
|
|
||||||
if !viper.GetBool("disable_check_updates") && !jsonOutput {
|
|
||||||
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 {
|
|
||||||
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.Execute()
|
cli.Execute()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,13 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"io/fs"
|
||||||
"io/ioutil"
|
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/juanfont/headscale/cmd/headscale/cli"
|
"github.com/juanfont/headscale"
|
||||||
"github.com/spf13/viper"
|
"github.com/spf13/viper"
|
||||||
"gopkg.in/check.v1"
|
"gopkg.in/check.v1"
|
||||||
)
|
)
|
||||||
@@ -25,11 +24,10 @@ func (s *Suite) SetUpSuite(c *check.C) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Suite) TearDownSuite(c *check.C) {
|
func (s *Suite) TearDownSuite(c *check.C) {
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (*Suite) TestPostgresConfigLoading(c *check.C) {
|
func (*Suite) TestConfigFileLoading(c *check.C) {
|
||||||
tmpDir, err := ioutil.TempDir("", "headscale")
|
tmpDir, err := os.MkdirTemp("", "headscale")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.Fatal(err)
|
c.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -40,63 +38,41 @@ func (*Suite) TestPostgresConfigLoading(c *check.C) {
|
|||||||
c.Fatal(err)
|
c.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
cfgFile := filepath.Join(tmpDir, "config.yaml")
|
||||||
|
|
||||||
// Symlink the example config file
|
// 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"),
|
||||||
|
cfgFile,
|
||||||
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.Fatal(err)
|
c.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load example config, it should load without validation errors
|
// Load example config, it should load without validation errors
|
||||||
err = cli.LoadConfig(tmpDir)
|
err = headscale.LoadConfig(cfgFile, true)
|
||||||
c.Assert(err, check.IsNil)
|
c.Assert(err, check.IsNil)
|
||||||
|
|
||||||
// Test that config file was interpreted correctly
|
// Test that config file was interpreted correctly
|
||||||
c.Assert(viper.GetString("server_url"), check.Equals, "http://127.0.0.1:8080")
|
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("listen_addr"), check.Equals, "127.0.0.1:8080")
|
||||||
c.Assert(viper.GetString("derp_map_path"), check.Equals, "derp.yaml")
|
c.Assert(viper.GetString("metrics_listen_addr"), check.Equals, "127.0.0.1:9090")
|
||||||
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_listen"), check.Equals, ":http")
|
|
||||||
c.Assert(viper.GetStringSlice("dns_config.nameservers")[0], check.Equals, "1.1.1.1")
|
|
||||||
}
|
|
||||||
|
|
||||||
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:8080")
|
|
||||||
c.Assert(viper.GetString("listen_addr"), check.Equals, "0.0.0.0:8080")
|
|
||||||
c.Assert(viper.GetString("derp_map_path"), check.Equals, "derp.yaml")
|
|
||||||
c.Assert(viper.GetString("db_type"), check.Equals, "sqlite3")
|
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_hostname"), check.Equals, "")
|
||||||
c.Assert(viper.GetString("tls_letsencrypt_listen"), check.Equals, ":http")
|
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.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(viper.GetStringSlice("dns_config.nameservers")[0], check.Equals, "1.1.1.1")
|
||||||
|
c.Assert(
|
||||||
|
headscale.GetFileMode("unix_socket_permission"),
|
||||||
|
check.Equals,
|
||||||
|
fs.FileMode(0o770),
|
||||||
|
)
|
||||||
|
c.Assert(viper.GetBool("logtail.enabled"), check.Equals, false)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (*Suite) TestDNSConfigLoading(c *check.C) {
|
func (*Suite) TestConfigLoading(c *check.C) {
|
||||||
tmpDir, err := ioutil.TempDir("", "headscale")
|
tmpDir, err := os.MkdirTemp("", "headscale")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.Fatal(err)
|
c.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -108,16 +84,63 @@ func (*Suite) TestDNSConfigLoading(c *check.C) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Symlink the example config file
|
// Symlink the example config file
|
||||||
err = os.Symlink(filepath.Clean(path+"/../../config.json.sqlite.example"), filepath.Join(tmpDir, "config.json"))
|
err = os.Symlink(
|
||||||
|
filepath.Clean(path+"/../../config-example.yaml"),
|
||||||
|
filepath.Join(tmpDir, "config.yaml"),
|
||||||
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.Fatal(err)
|
c.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load example config, it should load without validation errors
|
// Load example config, it should load without validation errors
|
||||||
err = cli.LoadConfig(tmpDir)
|
err = headscale.LoadConfig(tmpDir, false)
|
||||||
c.Assert(err, check.IsNil)
|
c.Assert(err, check.IsNil)
|
||||||
|
|
||||||
dnsConfig, baseDomain := cli.GetDNSConfig()
|
// Test that config file was interpreted correctly
|
||||||
|
c.Assert(viper.GetString("server_url"), check.Equals, "http://127.0.0.1:8080")
|
||||||
|
c.Assert(viper.GetString("listen_addr"), check.Equals, "127.0.0.1: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, "/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(
|
||||||
|
headscale.GetFileMode("unix_socket_permission"),
|
||||||
|
check.Equals,
|
||||||
|
fs.FileMode(0o770),
|
||||||
|
)
|
||||||
|
c.Assert(viper.GetBool("logtail.enabled"), check.Equals, false)
|
||||||
|
c.Assert(viper.GetBool("randomize_client_port"), check.Equals, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*Suite) TestDNSConfigLoading(c *check.C) {
|
||||||
|
tmpDir, err := os.MkdirTemp("", "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 = headscale.LoadConfig(tmpDir, false)
|
||||||
|
c.Assert(err, check.IsNil)
|
||||||
|
|
||||||
|
dnsConfig, baseDomain := headscale.GetDNSConfig()
|
||||||
|
|
||||||
c.Assert(dnsConfig.Nameservers[0].String(), check.Equals, "1.1.1.1")
|
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.Resolvers[0].Addr, check.Equals, "1.1.1.1")
|
||||||
@@ -128,36 +151,56 @@ func (*Suite) TestDNSConfigLoading(c *check.C) {
|
|||||||
func writeConfig(c *check.C, tmpDir string, configYaml []byte) {
|
func writeConfig(c *check.C, tmpDir string, configYaml []byte) {
|
||||||
// Populate a custom config file
|
// Populate a custom config file
|
||||||
configFile := filepath.Join(tmpDir, "config.yaml")
|
configFile := filepath.Join(tmpDir, "config.yaml")
|
||||||
err := ioutil.WriteFile(configFile, configYaml, 0644)
|
err := os.WriteFile(configFile, configYaml, 0o600)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.Fatalf("Couldn't write file %s", configFile)
|
c.Fatalf("Couldn't write file %s", configFile)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (*Suite) TestTLSConfigValidation(c *check.C) {
|
func (*Suite) TestTLSConfigValidation(c *check.C) {
|
||||||
tmpDir, err := ioutil.TempDir("", "headscale")
|
tmpDir, err := os.MkdirTemp("", "headscale")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.Fatal(err)
|
c.Fatal(err)
|
||||||
}
|
}
|
||||||
//defer os.RemoveAll(tmpDir)
|
// defer os.RemoveAll(tmpDir)
|
||||||
fmt.Println(tmpDir)
|
configYaml := []byte(`---
|
||||||
|
tls_letsencrypt_hostname: example.com
|
||||||
configYaml := []byte("---\ntls_letsencrypt_hostname: \"example.com\"\ntls_letsencrypt_challenge_type: \"\"\ntls_cert_path: \"abc.pem\"")
|
tls_letsencrypt_challenge_type: ""
|
||||||
|
tls_cert_path: abc.pem
|
||||||
|
noise:
|
||||||
|
private_key_path: noise_private.key`)
|
||||||
writeConfig(c, tmpDir, configYaml)
|
writeConfig(c, tmpDir, configYaml)
|
||||||
|
|
||||||
// Check configuration validation errors (1)
|
// Check configuration validation errors (1)
|
||||||
err = cli.LoadConfig(tmpDir)
|
err = headscale.LoadConfig(tmpDir, false)
|
||||||
c.Assert(err, check.NotNil)
|
c.Assert(err, check.NotNil)
|
||||||
// check.Matches can not handle multiline strings
|
// check.Matches can not handle multiline strings
|
||||||
tmp := strings.ReplaceAll(err.Error(), "\n", "***")
|
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(
|
||||||
c.Assert(tmp, check.Matches, ".*Fatal config error: the only supported values for tls_letsencrypt_challenge_type are.*")
|
tmp,
|
||||||
c.Assert(tmp, check.Matches, ".*Fatal config error: server_url must start with https:// or http://.*")
|
check.Matches,
|
||||||
fmt.Println(tmp)
|
".*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)
|
// Check configuration validation errors (2)
|
||||||
configYaml = []byte("---\nserver_url: \"http://127.0.0.1:8080\"\ntls_letsencrypt_hostname: \"example.com\"\ntls_letsencrypt_challenge_type: \"TLS-ALPN-01\"")
|
configYaml = []byte(`---
|
||||||
|
noise:
|
||||||
|
private_key_path: noise_private.key
|
||||||
|
server_url: http://127.0.0.1:8080
|
||||||
|
tls_letsencrypt_hostname: example.com
|
||||||
|
tls_letsencrypt_challenge_type: TLS-ALPN-01
|
||||||
|
`)
|
||||||
writeConfig(c, tmpDir, configYaml)
|
writeConfig(c, tmpDir, configYaml)
|
||||||
err = cli.LoadConfig(tmpDir)
|
err = headscale.LoadConfig(tmpDir, false)
|
||||||
c.Assert(err, check.IsNil)
|
c.Assert(err, check.IsNil)
|
||||||
}
|
}
|
||||||
|
|||||||
325
config-example.yaml
Normal file
325
config-example.yaml
Normal file
@@ -0,0 +1,325 @@
|
|||||||
|
---
|
||||||
|
# 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
|
||||||
|
#
|
||||||
|
# For production:
|
||||||
|
# listen_addr: 0.0.0.0:8080
|
||||||
|
listen_addr: 127.0.0.1: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.
|
||||||
|
#
|
||||||
|
# For production:
|
||||||
|
# grpc_listen_addr: 0.0.0.0:50443
|
||||||
|
grpc_listen_addr: 127.0.0.1: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 to encrypt the traffic between headscale
|
||||||
|
# and Tailscale clients.
|
||||||
|
# The private key file will be autogenerated if it's missing.
|
||||||
|
#
|
||||||
|
private_key_path: /var/lib/headscale/private.key
|
||||||
|
|
||||||
|
# The Noise section includes specific configuration for the
|
||||||
|
# TS2021 Noise protocol
|
||||||
|
noise:
|
||||||
|
# The Noise private key is used to encrypt the
|
||||||
|
# traffic between headscale and Tailscale clients when
|
||||||
|
# using the new Noise-based protocol. It must be different
|
||||||
|
# from the legacy private key.
|
||||||
|
private_key_path: /var/lib/headscale/noise_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.
|
||||||
|
# While this looks like it can take arbitrary values, it
|
||||||
|
# needs to be within IP ranges supported by the Tailscale
|
||||||
|
# client.
|
||||||
|
# IPv6: https://github.com/tailscale/tailscale/blob/22ebb25e833264f58d7c3f534a8b166894a89536/net/tsaddr/tsaddr.go#LL81C52-L81C71
|
||||||
|
# IPv4: https://github.com/tailscale/tailscale/blob/22ebb25e833264f58d7c3f534a8b166894a89536/net/tsaddr/tsaddr.go#L33
|
||||||
|
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 over UDP at the configured address for STUN connections - to help with 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
|
||||||
|
|
||||||
|
# Period to check for node updates within the tailnet. A value too low will severely affect
|
||||||
|
# CPU consumption of Headscale. A value too high (over 60s) will cause problems
|
||||||
|
# for the nodes, as they won't get updates or keep alive messages frequently enough.
|
||||||
|
# In case of doubts, do not touch the default 10s.
|
||||||
|
node_update_check_interval: 10s
|
||||||
|
|
||||||
|
# SQLite config
|
||||||
|
db_type: sqlite3
|
||||||
|
|
||||||
|
# For production:
|
||||||
|
db_path: /var/lib/headscale/db.sqlite
|
||||||
|
|
||||||
|
# # Postgres config
|
||||||
|
# If using a Unix socket to connect to Postgres, set the socket path in the 'host' field and leave 'port' blank.
|
||||||
|
# db_type: postgres
|
||||||
|
# db_host: localhost
|
||||||
|
# db_port: 5432
|
||||||
|
# db_name: headscale
|
||||||
|
# db_user: foo
|
||||||
|
# db_pass: bar
|
||||||
|
|
||||||
|
# If other 'sslmode' is required instead of 'require(true)' and 'disabled(false)', set the 'sslmode' you need
|
||||||
|
# in the 'db_ssl' field. Refers to https://www.postgresql.org/docs/current/libpq-ssl.html Table 34.1.
|
||||||
|
# db_ssl: false
|
||||||
|
|
||||||
|
### 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: ""
|
||||||
|
|
||||||
|
# Path to store certificates and metadata needed by
|
||||||
|
# letsencrypt
|
||||||
|
# For production:
|
||||||
|
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 listening on:
|
||||||
|
# :http = port 80
|
||||||
|
tls_letsencrypt_listen: ":http"
|
||||||
|
|
||||||
|
## Use already defined certificates:
|
||||||
|
tls_cert_path: ""
|
||||||
|
tls_key_path: ""
|
||||||
|
|
||||||
|
log:
|
||||||
|
# Output formatting for logs: text or json
|
||||||
|
format: text
|
||||||
|
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:
|
||||||
|
# Whether to prefer using Headscale provided DNS or use local.
|
||||||
|
override_local_dns: true
|
||||||
|
|
||||||
|
# List of DNS servers to expose to clients.
|
||||||
|
nameservers:
|
||||||
|
- 1.1.1.1
|
||||||
|
|
||||||
|
# NextDNS (see https://tailscale.com/kb/1218/nextdns/).
|
||||||
|
# "abc123" is example NextDNS ID, replace with yours.
|
||||||
|
#
|
||||||
|
# With metadata sharing:
|
||||||
|
# nameservers:
|
||||||
|
# - https://dns.nextdns.io/abc123
|
||||||
|
#
|
||||||
|
# Without metadata sharing:
|
||||||
|
# nameservers:
|
||||||
|
# - 2a07:a8c0::ab:c123
|
||||||
|
# - 2a07:a8c1::ab:c123
|
||||||
|
|
||||||
|
# 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: []
|
||||||
|
|
||||||
|
# Extra DNS records
|
||||||
|
# so far only A-records are supported (on the tailscale side)
|
||||||
|
# See https://github.com/juanfont/headscale/blob/main/docs/dns-records.md#Limitations
|
||||||
|
# extra_records:
|
||||||
|
# - name: "grafana.myvpn.example.com"
|
||||||
|
# type: "A"
|
||||||
|
# value: "100.64.0.3"
|
||||||
|
#
|
||||||
|
# # you can also put it in one line
|
||||||
|
# - { name: "prometheus.myvpn.example.com", type: "A", value: "100.64.0.3" }
|
||||||
|
|
||||||
|
# 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.user.base_domain` (e.g., _myhost.myuser.example.com_).
|
||||||
|
base_domain: example.com
|
||||||
|
|
||||||
|
# Unix socket used for the CLI to connect without authentication
|
||||||
|
# Note: for production you will want to set this to something like:
|
||||||
|
unix_socket: /var/run/headscale/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:
|
||||||
|
# only_start_if_oidc_is_available: true
|
||||||
|
# issuer: "https://your-oidc.issuer.com/path"
|
||||||
|
# client_id: "your-oidc-client-id"
|
||||||
|
# client_secret: "your-oidc-client-secret"
|
||||||
|
# # Alternatively, set `client_secret_path` to read the secret from the file.
|
||||||
|
# # It resolves environment variables, making integration to systemd's
|
||||||
|
# # `LoadCredential` straightforward:
|
||||||
|
# client_secret_path: "${CREDENTIALS_DIRECTORY}/oidc_client_secret"
|
||||||
|
# # client_secret and client_secret_path are mutually exclusive.
|
||||||
|
#
|
||||||
|
# # The amount of time from a node is authenticated with OpenID until it
|
||||||
|
# # expires and needs to reauthenticate.
|
||||||
|
# # Setting the value to "0" will mean no expiry.
|
||||||
|
# expiry: 180d
|
||||||
|
#
|
||||||
|
# # Use the expiry from the token received from OpenID when the user logged
|
||||||
|
# # in, this will typically lead to frequent need to reauthenticate and should
|
||||||
|
# # only been enabled if you know what you are doing.
|
||||||
|
# # Note: enabling this will cause `oidc.expiry` to be ignored.
|
||||||
|
# use_expiry_from_token: false
|
||||||
|
#
|
||||||
|
# # Customize the scopes used in the OIDC flow, defaults to "openid", "profile" and "email" and add custom query
|
||||||
|
# # parameters to the Authorize Endpoint request. Scopes default to "openid", "profile" and "email".
|
||||||
|
#
|
||||||
|
# scope: ["openid", "profile", "email", "custom"]
|
||||||
|
# extra_params:
|
||||||
|
# domain_hint: example.com
|
||||||
|
#
|
||||||
|
# # List allowed principal domains and/or users. If an authenticated user's domain is not in this list, the
|
||||||
|
# # authentication request will be rejected.
|
||||||
|
#
|
||||||
|
# allowed_domains:
|
||||||
|
# - example.com
|
||||||
|
# # Note: Groups from keycloak have a leading '/'
|
||||||
|
# allowed_groups:
|
||||||
|
# - /headscale
|
||||||
|
# allowed_users:
|
||||||
|
# - alice@example.com
|
||||||
|
#
|
||||||
|
# # 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 user `first-name.last-name`
|
||||||
|
# # If `strip_email_domain` is set to `false` the domain part will NOT be removed resulting to the following
|
||||||
|
# user: `first-name.last-name.example.com`
|
||||||
|
#
|
||||||
|
# strip_email_domain: true
|
||||||
|
|
||||||
|
# Logtail configuration
|
||||||
|
# Logtail is Tailscales logging and auditing infrastructure, it allows the control panel
|
||||||
|
# to instruct tailscale nodes to log their activity to a remote server.
|
||||||
|
logtail:
|
||||||
|
# Enable logtail for this headscales clients.
|
||||||
|
# As there is currently no support for overriding the log server in headscale, this is
|
||||||
|
# disabled by default. Enabling this will make your clients send logs to Tailscale Inc.
|
||||||
|
enabled: false
|
||||||
|
|
||||||
|
# Enabling this option makes devices prefer a random port for WireGuard traffic over the
|
||||||
|
# default static port 41641. This option is intended as a workaround for some buggy
|
||||||
|
# firewall devices. See https://tailscale.com/kb/1181/firewalls/ for more information.
|
||||||
|
randomize_client_port: false
|
||||||
650
config.go
Normal file
650
config.go
Normal file
@@ -0,0 +1,650 @@
|
|||||||
|
package headscale
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io/fs"
|
||||||
|
"net/netip"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/coreos/go-oidc/v3/oidc"
|
||||||
|
"github.com/prometheus/common/model"
|
||||||
|
"github.com/rs/zerolog"
|
||||||
|
"github.com/rs/zerolog/log"
|
||||||
|
"github.com/spf13/viper"
|
||||||
|
"go4.org/netipx"
|
||||||
|
"tailscale.com/tailcfg"
|
||||||
|
"tailscale.com/types/dnstype"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
tlsALPN01ChallengeType = "TLS-ALPN-01"
|
||||||
|
http01ChallengeType = "HTTP-01"
|
||||||
|
|
||||||
|
JSONLogFormat = "json"
|
||||||
|
TextLogFormat = "text"
|
||||||
|
|
||||||
|
defaultOIDCExpiryTime = 180 * 24 * time.Hour // 180 Days
|
||||||
|
maxDuration time.Duration = 1<<63 - 1
|
||||||
|
)
|
||||||
|
|
||||||
|
var errOidcMutuallyExclusive = errors.New(
|
||||||
|
"oidc_client_secret and oidc_client_secret_path are mutually exclusive",
|
||||||
|
)
|
||||||
|
|
||||||
|
// Config contains the initial Headscale configuration.
|
||||||
|
type Config struct {
|
||||||
|
ServerURL string
|
||||||
|
Addr string
|
||||||
|
MetricsAddr string
|
||||||
|
GRPCAddr string
|
||||||
|
GRPCAllowInsecure bool
|
||||||
|
EphemeralNodeInactivityTimeout time.Duration
|
||||||
|
NodeUpdateCheckInterval time.Duration
|
||||||
|
IPPrefixes []netip.Prefix
|
||||||
|
PrivateKeyPath string
|
||||||
|
NoisePrivateKeyPath string
|
||||||
|
BaseDomain string
|
||||||
|
Log LogConfig
|
||||||
|
DisableUpdateCheck bool
|
||||||
|
|
||||||
|
DERP DERPConfig
|
||||||
|
|
||||||
|
DBtype string
|
||||||
|
DBpath string
|
||||||
|
DBhost string
|
||||||
|
DBport int
|
||||||
|
DBname string
|
||||||
|
DBuser string
|
||||||
|
DBpass string
|
||||||
|
DBssl string
|
||||||
|
|
||||||
|
TLS TLSConfig
|
||||||
|
|
||||||
|
ACMEURL string
|
||||||
|
ACMEEmail string
|
||||||
|
|
||||||
|
DNSConfig *tailcfg.DNSConfig
|
||||||
|
|
||||||
|
UnixSocket string
|
||||||
|
UnixSocketPermission fs.FileMode
|
||||||
|
|
||||||
|
OIDC OIDCConfig
|
||||||
|
|
||||||
|
LogTail LogTailConfig
|
||||||
|
RandomizeClientPort bool
|
||||||
|
|
||||||
|
CLI CLIConfig
|
||||||
|
|
||||||
|
ACL ACLConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
type TLSConfig struct {
|
||||||
|
CertPath string
|
||||||
|
KeyPath string
|
||||||
|
|
||||||
|
LetsEncrypt LetsEncryptConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
type LetsEncryptConfig struct {
|
||||||
|
Listen string
|
||||||
|
Hostname string
|
||||||
|
CacheDir string
|
||||||
|
ChallengeType string
|
||||||
|
}
|
||||||
|
|
||||||
|
type OIDCConfig struct {
|
||||||
|
OnlyStartIfOIDCIsAvailable bool
|
||||||
|
Issuer string
|
||||||
|
ClientID string
|
||||||
|
ClientSecret string
|
||||||
|
Scope []string
|
||||||
|
ExtraParams map[string]string
|
||||||
|
AllowedDomains []string
|
||||||
|
AllowedUsers []string
|
||||||
|
AllowedGroups []string
|
||||||
|
StripEmaildomain bool
|
||||||
|
Expiry time.Duration
|
||||||
|
UseExpiryFromToken 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 LogTailConfig struct {
|
||||||
|
Enabled bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type CLIConfig struct {
|
||||||
|
Address string
|
||||||
|
APIKey string
|
||||||
|
Timeout time.Duration
|
||||||
|
Insecure bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type ACLConfig struct {
|
||||||
|
PolicyPath string
|
||||||
|
}
|
||||||
|
|
||||||
|
type LogConfig struct {
|
||||||
|
Format string
|
||||||
|
Level zerolog.Level
|
||||||
|
}
|
||||||
|
|
||||||
|
func LoadConfig(path string, isFile bool) error {
|
||||||
|
if isFile {
|
||||||
|
viper.SetConfigFile(path)
|
||||||
|
} else {
|
||||||
|
viper.SetConfigName("config")
|
||||||
|
if path == "" {
|
||||||
|
viper.AddConfigPath("/etc/headscale/")
|
||||||
|
viper.AddConfigPath("$HOME/.headscale")
|
||||||
|
viper.AddConfigPath(".")
|
||||||
|
} else {
|
||||||
|
// 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", http01ChallengeType)
|
||||||
|
|
||||||
|
viper.SetDefault("log.level", "info")
|
||||||
|
viper.SetDefault("log.format", TextLogFormat)
|
||||||
|
|
||||||
|
viper.SetDefault("dns_config", nil)
|
||||||
|
viper.SetDefault("dns_config.override_local_dns", true)
|
||||||
|
|
||||||
|
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("db_ssl", false)
|
||||||
|
|
||||||
|
viper.SetDefault("oidc.scope", []string{oidc.ScopeOpenID, "profile", "email"})
|
||||||
|
viper.SetDefault("oidc.strip_email_domain", true)
|
||||||
|
viper.SetDefault("oidc.only_start_if_oidc_is_available", true)
|
||||||
|
viper.SetDefault("oidc.expiry", "180d")
|
||||||
|
viper.SetDefault("oidc.use_expiry_from_token", false)
|
||||||
|
|
||||||
|
viper.SetDefault("logtail.enabled", false)
|
||||||
|
viper.SetDefault("randomize_client_port", false)
|
||||||
|
|
||||||
|
viper.SetDefault("ephemeral_node_inactivity_timeout", "120s")
|
||||||
|
|
||||||
|
viper.SetDefault("node_update_check_interval", "10s")
|
||||||
|
|
||||||
|
if IsCLIConfigured() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := viper.ReadInConfig(); err != nil {
|
||||||
|
log.Warn().Err(err).Msg("Failed to read configuration from disk")
|
||||||
|
|
||||||
|
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") != "")) {
|
||||||
|
errorText += "Fatal config error: set either tls_letsencrypt_hostname or tls_cert_path/tls_key_path, not both\n"
|
||||||
|
}
|
||||||
|
|
||||||
|
if !viper.IsSet("noise") || viper.GetString("noise.private_key_path") == "" {
|
||||||
|
errorText += "Fatal config error: headscale now requires a new `noise.private_key_path` field in the config file for the Tailscale v2 protocol\n"
|
||||||
|
}
|
||||||
|
|
||||||
|
if (viper.GetString("tls_letsencrypt_hostname") != "") &&
|
||||||
|
(viper.GetString("tls_letsencrypt_challenge_type") == tlsALPN01ChallengeType) &&
|
||||||
|
(!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") != http01ChallengeType) &&
|
||||||
|
(viper.GetString("tls_letsencrypt_challenge_type") != tlsALPN01ChallengeType) {
|
||||||
|
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://") {
|
||||||
|
errorText += "Fatal config error: server_url must start with https:// or http://\n"
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 {
|
||||||
|
errorText += fmt.Sprintf(
|
||||||
|
"Fatal config error: ephemeral_node_inactivity_timeout (%s) is set too low, must be more than %s",
|
||||||
|
viper.GetString("ephemeral_node_inactivity_timeout"),
|
||||||
|
minInactivityTimeout,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
maxNodeUpdateCheckInterval, _ := time.ParseDuration("60s")
|
||||||
|
if viper.GetDuration("node_update_check_interval") > maxNodeUpdateCheckInterval {
|
||||||
|
errorText += fmt.Sprintf(
|
||||||
|
"Fatal config error: node_update_check_interval (%s) is set too high, must be less than %s",
|
||||||
|
viper.GetString("node_update_check_interval"),
|
||||||
|
maxNodeUpdateCheckInterval,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if errorText != "" {
|
||||||
|
//nolint
|
||||||
|
return errors.New(strings.TrimSuffix(errorText, "\n"))
|
||||||
|
} else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetTLSConfig() TLSConfig {
|
||||||
|
return TLSConfig{
|
||||||
|
LetsEncrypt: LetsEncryptConfig{
|
||||||
|
Hostname: viper.GetString("tls_letsencrypt_hostname"),
|
||||||
|
Listen: viper.GetString("tls_letsencrypt_listen"),
|
||||||
|
CacheDir: AbsolutePathFromConfigPath(
|
||||||
|
viper.GetString("tls_letsencrypt_cache_dir"),
|
||||||
|
),
|
||||||
|
ChallengeType: viper.GetString("tls_letsencrypt_challenge_type"),
|
||||||
|
},
|
||||||
|
CertPath: AbsolutePathFromConfigPath(
|
||||||
|
viper.GetString("tls_cert_path"),
|
||||||
|
),
|
||||||
|
KeyPath: AbsolutePathFromConfigPath(
|
||||||
|
viper.GetString("tls_key_path"),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetDERPConfig() 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 DERPConfig{
|
||||||
|
ServerEnabled: serverEnabled,
|
||||||
|
ServerRegionID: serverRegionID,
|
||||||
|
ServerRegionCode: serverRegionCode,
|
||||||
|
ServerRegionName: serverRegionName,
|
||||||
|
STUNAddr: stunAddr,
|
||||||
|
URLs: urls,
|
||||||
|
Paths: paths,
|
||||||
|
AutoUpdate: autoUpdate,
|
||||||
|
UpdateFrequency: updateFrequency,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetLogTailConfig() LogTailConfig {
|
||||||
|
enabled := viper.GetBool("logtail.enabled")
|
||||||
|
|
||||||
|
return LogTailConfig{
|
||||||
|
Enabled: enabled,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetACLConfig() ACLConfig {
|
||||||
|
policyPath := viper.GetString("acl_policy_path")
|
||||||
|
|
||||||
|
return ACLConfig{
|
||||||
|
PolicyPath: policyPath,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetLogConfig() LogConfig {
|
||||||
|
logLevelStr := viper.GetString("log.level")
|
||||||
|
logLevel, err := zerolog.ParseLevel(logLevelStr)
|
||||||
|
if err != nil {
|
||||||
|
logLevel = zerolog.DebugLevel
|
||||||
|
}
|
||||||
|
|
||||||
|
logFormatOpt := viper.GetString("log.format")
|
||||||
|
var logFormat string
|
||||||
|
switch logFormatOpt {
|
||||||
|
case "json":
|
||||||
|
logFormat = JSONLogFormat
|
||||||
|
case "text":
|
||||||
|
logFormat = TextLogFormat
|
||||||
|
case "":
|
||||||
|
logFormat = TextLogFormat
|
||||||
|
default:
|
||||||
|
log.Error().
|
||||||
|
Str("func", "GetLogConfig").
|
||||||
|
Msgf("Could not parse log format: %s. Valid choices are 'json' or 'text'", logFormatOpt)
|
||||||
|
}
|
||||||
|
|
||||||
|
return LogConfig{
|
||||||
|
Format: logFormat,
|
||||||
|
Level: logLevel,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetDNSConfig() (*tailcfg.DNSConfig, string) {
|
||||||
|
if viper.IsSet("dns_config") {
|
||||||
|
dnsConfig := &tailcfg.DNSConfig{}
|
||||||
|
|
||||||
|
overrideLocalDNS := viper.GetBool("dns_config.override_local_dns")
|
||||||
|
|
||||||
|
if viper.IsSet("dns_config.nameservers") {
|
||||||
|
nameserversStr := viper.GetStringSlice("dns_config.nameservers")
|
||||||
|
|
||||||
|
nameservers := []netip.Addr{}
|
||||||
|
resolvers := []*dnstype.Resolver{}
|
||||||
|
|
||||||
|
for _, nameserverStr := range nameserversStr {
|
||||||
|
// Search for explicit DNS-over-HTTPS resolvers
|
||||||
|
if strings.HasPrefix(nameserverStr, "https://") {
|
||||||
|
resolvers = append(resolvers, &dnstype.Resolver{
|
||||||
|
Addr: nameserverStr,
|
||||||
|
})
|
||||||
|
|
||||||
|
// This nameserver can not be parsed as an IP address
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse nameserver as a regular IP
|
||||||
|
nameserver, err := netip.ParseAddr(nameserverStr)
|
||||||
|
if err != nil {
|
||||||
|
log.Error().
|
||||||
|
Str("func", "getDNSConfig").
|
||||||
|
Err(err).
|
||||||
|
Msgf("Could not parse nameserver IP: %s", nameserverStr)
|
||||||
|
}
|
||||||
|
|
||||||
|
nameservers = append(nameservers, nameserver)
|
||||||
|
resolvers = append(resolvers, &dnstype.Resolver{
|
||||||
|
Addr: nameserver.String(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
dnsConfig.Nameservers = nameservers
|
||||||
|
|
||||||
|
if overrideLocalDNS {
|
||||||
|
dnsConfig.Resolvers = resolvers
|
||||||
|
} else {
|
||||||
|
dnsConfig.FallbackResolvers = resolvers
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if viper.IsSet("dns_config.restricted_nameservers") {
|
||||||
|
dnsConfig.Routes = make(map[string][]*dnstype.Resolver)
|
||||||
|
domains := []string{}
|
||||||
|
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 := netip.ParseAddr(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
|
||||||
|
domains = append(domains, domain)
|
||||||
|
}
|
||||||
|
dnsConfig.Domains = domains
|
||||||
|
}
|
||||||
|
|
||||||
|
if viper.IsSet("dns_config.domains") {
|
||||||
|
domains := viper.GetStringSlice("dns_config.domains")
|
||||||
|
if len(dnsConfig.Resolvers) > 0 {
|
||||||
|
dnsConfig.Domains = domains
|
||||||
|
} else if domains != nil {
|
||||||
|
log.Warn().
|
||||||
|
Msg("Warning: dns_config.domains is set, but no nameservers are configured. Ignoring domains.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if viper.IsSet("dns_config.extra_records") {
|
||||||
|
var extraRecords []tailcfg.DNSRecord
|
||||||
|
|
||||||
|
err := viper.UnmarshalKey("dns_config.extra_records", &extraRecords)
|
||||||
|
if err != nil {
|
||||||
|
log.Error().
|
||||||
|
Str("func", "getDNSConfig").
|
||||||
|
Err(err).
|
||||||
|
Msgf("Could not parse dns_config.extra_records")
|
||||||
|
}
|
||||||
|
|
||||||
|
dnsConfig.ExtraRecords = extraRecords
|
||||||
|
}
|
||||||
|
|
||||||
|
if viper.IsSet("dns_config.magic_dns") {
|
||||||
|
dnsConfig.Proxied = viper.GetBool("dns_config.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 GetHeadscaleConfig() (*Config, error) {
|
||||||
|
if IsCLIConfigured() {
|
||||||
|
return &Config{
|
||||||
|
CLI: CLIConfig{
|
||||||
|
Address: viper.GetString("cli.address"),
|
||||||
|
APIKey: viper.GetString("cli.api_key"),
|
||||||
|
Timeout: viper.GetDuration("cli.timeout"),
|
||||||
|
Insecure: viper.GetBool("cli.insecure"),
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
dnsConfig, baseDomain := GetDNSConfig()
|
||||||
|
derpConfig := GetDERPConfig()
|
||||||
|
logConfig := GetLogTailConfig()
|
||||||
|
randomizeClientPort := viper.GetBool("randomize_client_port")
|
||||||
|
|
||||||
|
configuredPrefixes := viper.GetStringSlice("ip_prefixes")
|
||||||
|
parsedPrefixes := make([]netip.Prefix, 0, len(configuredPrefixes)+1)
|
||||||
|
|
||||||
|
for i, prefixInConfig := range configuredPrefixes {
|
||||||
|
prefix, err := netip.ParsePrefix(prefixInConfig)
|
||||||
|
if err != nil {
|
||||||
|
panic(fmt.Errorf("failed to parse ip_prefixes[%d]: %w", i, err))
|
||||||
|
}
|
||||||
|
parsedPrefixes = append(parsedPrefixes, prefix)
|
||||||
|
}
|
||||||
|
|
||||||
|
prefixes := make([]netip.Prefix, 0, len(parsedPrefixes))
|
||||||
|
{
|
||||||
|
// dedup
|
||||||
|
normalizedPrefixes := make(map[string]int, len(parsedPrefixes))
|
||||||
|
for i, p := range parsedPrefixes {
|
||||||
|
normalized, _ := netipx.RangeOfPrefix(p).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, netip.MustParsePrefix("100.64.0.0/10"))
|
||||||
|
log.Warn().
|
||||||
|
Msgf("'ip_prefixes' not configured, falling back to default: %v", prefixes)
|
||||||
|
}
|
||||||
|
|
||||||
|
oidcClientSecret := viper.GetString("oidc.client_secret")
|
||||||
|
oidcClientSecretPath := viper.GetString("oidc.client_secret_path")
|
||||||
|
if oidcClientSecretPath != "" && oidcClientSecret != "" {
|
||||||
|
return nil, errOidcMutuallyExclusive
|
||||||
|
}
|
||||||
|
if oidcClientSecretPath != "" {
|
||||||
|
secretBytes, err := os.ReadFile(os.ExpandEnv(oidcClientSecretPath))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
oidcClientSecret = string(secretBytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &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"),
|
||||||
|
DisableUpdateCheck: viper.GetBool("disable_check_updates"),
|
||||||
|
|
||||||
|
IPPrefixes: prefixes,
|
||||||
|
PrivateKeyPath: AbsolutePathFromConfigPath(
|
||||||
|
viper.GetString("private_key_path"),
|
||||||
|
),
|
||||||
|
NoisePrivateKeyPath: AbsolutePathFromConfigPath(
|
||||||
|
viper.GetString("noise.private_key_path"),
|
||||||
|
),
|
||||||
|
BaseDomain: baseDomain,
|
||||||
|
|
||||||
|
DERP: derpConfig,
|
||||||
|
|
||||||
|
EphemeralNodeInactivityTimeout: viper.GetDuration(
|
||||||
|
"ephemeral_node_inactivity_timeout",
|
||||||
|
),
|
||||||
|
|
||||||
|
NodeUpdateCheckInterval: viper.GetDuration(
|
||||||
|
"node_update_check_interval",
|
||||||
|
),
|
||||||
|
|
||||||
|
DBtype: viper.GetString("db_type"),
|
||||||
|
DBpath: AbsolutePathFromConfigPath(viper.GetString("db_path")),
|
||||||
|
DBhost: viper.GetString("db_host"),
|
||||||
|
DBport: viper.GetInt("db_port"),
|
||||||
|
DBname: viper.GetString("db_name"),
|
||||||
|
DBuser: viper.GetString("db_user"),
|
||||||
|
DBpass: viper.GetString("db_pass"),
|
||||||
|
DBssl: viper.GetString("db_ssl"),
|
||||||
|
|
||||||
|
TLS: GetTLSConfig(),
|
||||||
|
|
||||||
|
DNSConfig: dnsConfig,
|
||||||
|
|
||||||
|
ACMEEmail: viper.GetString("acme_email"),
|
||||||
|
ACMEURL: viper.GetString("acme_url"),
|
||||||
|
|
||||||
|
UnixSocket: viper.GetString("unix_socket"),
|
||||||
|
UnixSocketPermission: GetFileMode("unix_socket_permission"),
|
||||||
|
|
||||||
|
OIDC: OIDCConfig{
|
||||||
|
OnlyStartIfOIDCIsAvailable: viper.GetBool(
|
||||||
|
"oidc.only_start_if_oidc_is_available",
|
||||||
|
),
|
||||||
|
Issuer: viper.GetString("oidc.issuer"),
|
||||||
|
ClientID: viper.GetString("oidc.client_id"),
|
||||||
|
ClientSecret: oidcClientSecret,
|
||||||
|
Scope: viper.GetStringSlice("oidc.scope"),
|
||||||
|
ExtraParams: viper.GetStringMapString("oidc.extra_params"),
|
||||||
|
AllowedDomains: viper.GetStringSlice("oidc.allowed_domains"),
|
||||||
|
AllowedUsers: viper.GetStringSlice("oidc.allowed_users"),
|
||||||
|
AllowedGroups: viper.GetStringSlice("oidc.allowed_groups"),
|
||||||
|
StripEmaildomain: viper.GetBool("oidc.strip_email_domain"),
|
||||||
|
Expiry: func() time.Duration {
|
||||||
|
// if set to 0, we assume no expiry
|
||||||
|
if value := viper.GetString("oidc.expiry"); value == "0" {
|
||||||
|
return maxDuration
|
||||||
|
} else {
|
||||||
|
expiry, err := model.ParseDuration(value)
|
||||||
|
if err != nil {
|
||||||
|
log.Warn().Msg("failed to parse oidc.expiry, defaulting back to 180 days")
|
||||||
|
|
||||||
|
return defaultOIDCExpiryTime
|
||||||
|
}
|
||||||
|
|
||||||
|
return time.Duration(expiry)
|
||||||
|
}
|
||||||
|
}(),
|
||||||
|
UseExpiryFromToken: viper.GetBool("oidc.use_expiry_from_token"),
|
||||||
|
},
|
||||||
|
|
||||||
|
LogTail: logConfig,
|
||||||
|
RandomizeClientPort: randomizeClientPort,
|
||||||
|
|
||||||
|
ACL: GetACLConfig(),
|
||||||
|
|
||||||
|
CLI: CLIConfig{
|
||||||
|
Address: viper.GetString("cli.address"),
|
||||||
|
APIKey: viper.GetString("cli.api_key"),
|
||||||
|
Timeout: viper.GetDuration("cli.timeout"),
|
||||||
|
Insecure: viper.GetBool("cli.insecure"),
|
||||||
|
},
|
||||||
|
|
||||||
|
Log: GetLogConfig(),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func IsCLIConfigured() bool {
|
||||||
|
return viper.GetString("cli.address") != "" && viper.GetString("cli.api_key") != ""
|
||||||
|
}
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
{
|
|
||||||
"server_url": "http://127.0.0.1:8080",
|
|
||||||
"listen_addr": "0.0.0.0:8080",
|
|
||||||
"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",
|
|
||||||
"acme_url": "https://acme-v02.api.letsencrypt.org/directory",
|
|
||||||
"acme_email": "",
|
|
||||||
"tls_letsencrypt_hostname": "",
|
|
||||||
"tls_letsencrypt_listen": ":http",
|
|
||||||
"tls_letsencrypt_cache_dir": ".cache",
|
|
||||||
"tls_letsencrypt_challenge_type": "HTTP-01",
|
|
||||||
"tls_cert_path": "",
|
|
||||||
"tls_key_path": "",
|
|
||||||
"acl_policy_path": "",
|
|
||||||
"dns_config": {
|
|
||||||
"nameservers": [
|
|
||||||
"1.1.1.1"
|
|
||||||
],
|
|
||||||
"domains": [],
|
|
||||||
"magic_dns": true,
|
|
||||||
"base_domain": "example.com"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
{
|
|
||||||
"server_url": "http://127.0.0.1:8080",
|
|
||||||
"listen_addr": "0.0.0.0:8080",
|
|
||||||
"private_key_path": "private.key",
|
|
||||||
"derp_map_path": "derp.yaml",
|
|
||||||
"ephemeral_node_inactivity_timeout": "30m",
|
|
||||||
"db_type": "sqlite3",
|
|
||||||
"db_path": "db.sqlite",
|
|
||||||
"acme_url": "https://acme-v02.api.letsencrypt.org/directory",
|
|
||||||
"acme_email": "",
|
|
||||||
"tls_letsencrypt_hostname": "",
|
|
||||||
"tls_letsencrypt_listen": ":http",
|
|
||||||
"tls_letsencrypt_cache_dir": ".cache",
|
|
||||||
"tls_letsencrypt_challenge_type": "HTTP-01",
|
|
||||||
"tls_cert_path": "",
|
|
||||||
"tls_key_path": "",
|
|
||||||
"acl_policy_path": "",
|
|
||||||
"dns_config": {
|
|
||||||
"nameservers": [
|
|
||||||
"1.1.1.1"
|
|
||||||
],
|
|
||||||
"domains": [],
|
|
||||||
"magic_dns": true,
|
|
||||||
"base_domain": "example.com"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
341
db.go
341
db.go
@@ -1,15 +1,28 @@
|
|||||||
package headscale
|
package headscale
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql/driver"
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/netip"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/glebarez/sqlite"
|
||||||
|
"github.com/rs/zerolog/log"
|
||||||
"gorm.io/driver/postgres"
|
"gorm.io/driver/postgres"
|
||||||
"gorm.io/driver/sqlite"
|
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
"gorm.io/gorm/logger"
|
"gorm.io/gorm/logger"
|
||||||
|
"tailscale.com/tailcfg"
|
||||||
)
|
)
|
||||||
|
|
||||||
const dbVersion = "1"
|
const (
|
||||||
|
dbVersion = "1"
|
||||||
|
|
||||||
|
errValueNotFound = Error("not found")
|
||||||
|
ErrCannotParsePrefix = Error("cannot parse prefix")
|
||||||
|
)
|
||||||
|
|
||||||
// KV is a key-value store in a psql table. For future use...
|
// KV is a key-value store in a psql table. For future use...
|
||||||
type KV struct {
|
type KV struct {
|
||||||
@@ -24,32 +37,185 @@ func (h *Headscale) initDB() error {
|
|||||||
}
|
}
|
||||||
h.db = db
|
h.db = db
|
||||||
|
|
||||||
if h.dbType == "postgres" {
|
if h.dbType == Postgres {
|
||||||
db.Exec("create extension if not exists \"uuid-ossp\";")
|
db.Exec(`create extension if not exists "uuid-ossp";`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_ = db.Migrator().RenameTable("namespaces", "users")
|
||||||
|
|
||||||
|
err = db.AutoMigrate(&User{})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = db.Migrator().RenameColumn(&Machine{}, "namespace_id", "user_id")
|
||||||
|
_ = db.Migrator().RenameColumn(&PreAuthKey{}, "namespace_id", "user_id")
|
||||||
|
|
||||||
|
_ = db.Migrator().RenameColumn(&Machine{}, "ip_address", "ip_addresses")
|
||||||
|
_ = db.Migrator().RenameColumn(&Machine{}, "name", "hostname")
|
||||||
|
|
||||||
|
// GivenName is used as the primary source of DNS names, make sure
|
||||||
|
// the field is populated and normalized if it was not when the
|
||||||
|
// machine was registered.
|
||||||
|
_ = db.Migrator().RenameColumn(&Machine{}, "nickname", "given_name")
|
||||||
|
|
||||||
|
// 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.Hostname).
|
||||||
|
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.Hostname).
|
||||||
|
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(&Route{})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if db.Migrator().HasColumn(&Machine{}, "enabled_routes") {
|
||||||
|
log.Info().Msgf("Database has legacy enabled_routes column in machine, migrating...")
|
||||||
|
|
||||||
|
type MachineAux struct {
|
||||||
|
ID uint64
|
||||||
|
EnabledRoutes IPPrefixes
|
||||||
|
}
|
||||||
|
|
||||||
|
machinesAux := []MachineAux{}
|
||||||
|
err := db.Table("machines").Select("id, enabled_routes").Scan(&machinesAux).Error
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal().Err(err).Msg("Error accessing db")
|
||||||
|
}
|
||||||
|
for _, machine := range machinesAux {
|
||||||
|
for _, prefix := range machine.EnabledRoutes {
|
||||||
|
if err != nil {
|
||||||
|
log.Error().
|
||||||
|
Err(err).
|
||||||
|
Str("enabled_route", prefix.String()).
|
||||||
|
Msg("Error parsing enabled_route")
|
||||||
|
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
err = db.Preload("Machine").
|
||||||
|
Where("machine_id = ? AND prefix = ?", machine.ID, IPPrefix(prefix)).
|
||||||
|
First(&Route{}).
|
||||||
|
Error
|
||||||
|
if err == nil {
|
||||||
|
log.Info().
|
||||||
|
Str("enabled_route", prefix.String()).
|
||||||
|
Msg("Route already migrated to new table, skipping")
|
||||||
|
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
route := Route{
|
||||||
|
MachineID: machine.ID,
|
||||||
|
Advertised: true,
|
||||||
|
Enabled: true,
|
||||||
|
Prefix: IPPrefix(prefix),
|
||||||
|
}
|
||||||
|
if err := h.db.Create(&route).Error; err != nil {
|
||||||
|
log.Error().Err(err).Msg("Error creating route")
|
||||||
|
} else {
|
||||||
|
log.Info().
|
||||||
|
Uint64("machine_id", route.MachineID).
|
||||||
|
Str("prefix", prefix.String()).
|
||||||
|
Msg("Route migrated")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
err = db.Migrator().DropColumn(&Machine{}, "enabled_routes")
|
||||||
|
if err != nil {
|
||||||
|
log.Error().Err(err).Msg("Error dropping enabled_routes column")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
err = db.AutoMigrate(&Machine{})
|
err = db.AutoMigrate(&Machine{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if db.Migrator().HasColumn(&Machine{}, "given_name") {
|
||||||
|
machines := Machines{}
|
||||||
|
if err := h.db.Find(&machines).Error; err != nil {
|
||||||
|
log.Error().Err(err).Msg("Error accessing db")
|
||||||
|
}
|
||||||
|
|
||||||
|
for item, machine := range machines {
|
||||||
|
if machine.GivenName == "" {
|
||||||
|
normalizedHostname, err := NormalizeToFQDNRules(
|
||||||
|
machine.Hostname,
|
||||||
|
h.cfg.OIDC.StripEmaildomain,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
log.Error().
|
||||||
|
Caller().
|
||||||
|
Str("hostname", machine.Hostname).
|
||||||
|
Err(err).
|
||||||
|
Msg("Failed to normalize machine hostname in DB migration")
|
||||||
|
}
|
||||||
|
|
||||||
|
err = h.RenameMachine(&machines[item], normalizedHostname)
|
||||||
|
if err != nil {
|
||||||
|
log.Error().
|
||||||
|
Caller().
|
||||||
|
Str("hostname", machine.Hostname).
|
||||||
|
Err(err).
|
||||||
|
Msg("Failed to save normalized machine name in DB migration")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
err = db.AutoMigrate(&KV{})
|
err = db.AutoMigrate(&KV{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
err = db.AutoMigrate(&Namespace{})
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
err = db.AutoMigrate(&PreAuthKey{})
|
err = db.AutoMigrate(&PreAuthKey{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
err = db.AutoMigrate(&SharedMachine{})
|
err = db.AutoMigrate(&PreAuthKeyACLTag{})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = db.Migrator().DropTable("shared_machines")
|
||||||
|
|
||||||
|
err = db.AutoMigrate(&APIKey{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
err = h.setValue("db_version", dbVersion)
|
err = h.setValue("db_version", dbVersion)
|
||||||
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,12 +231,26 @@ func (h *Headscale) openDB() (*gorm.DB, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
switch h.dbType {
|
switch h.dbType {
|
||||||
case "sqlite3":
|
case Sqlite:
|
||||||
db, err = gorm.Open(sqlite.Open(h.dbString), &gorm.Config{
|
db, err = gorm.Open(
|
||||||
DisableForeignKeyConstraintWhenMigrating: true,
|
sqlite.Open(h.dbString+"?_synchronous=1&_journal_mode=WAL"),
|
||||||
Logger: log,
|
&gorm.Config{
|
||||||
})
|
DisableForeignKeyConstraintWhenMigrating: true,
|
||||||
case "postgres":
|
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{
|
db, err = gorm.Open(postgres.Open(h.dbString), &gorm.Config{
|
||||||
DisableForeignKeyConstraintWhenMigrating: true,
|
DisableForeignKeyConstraintWhenMigrating: true,
|
||||||
Logger: log,
|
Logger: log,
|
||||||
@@ -84,28 +264,141 @@ func (h *Headscale) openDB() (*gorm.DB, error) {
|
|||||||
return db, nil
|
return db, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// getValue returns the value for the given key in KV
|
// getValue returns the value for the given key in KV.
|
||||||
func (h *Headscale) getValue(key string) (string, error) {
|
func (h *Headscale) getValue(key string) (string, error) {
|
||||||
var row KV
|
var row KV
|
||||||
if result := h.db.First(&row, "key = ?", key); errors.Is(result.Error, gorm.ErrRecordNotFound) {
|
if result := h.db.First(&row, "key = ?", key); errors.Is(
|
||||||
return "", errors.New("not found")
|
result.Error,
|
||||||
|
gorm.ErrRecordNotFound,
|
||||||
|
) {
|
||||||
|
return "", errValueNotFound
|
||||||
}
|
}
|
||||||
|
|
||||||
return row.Value, nil
|
return row.Value, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// setValue sets value for the given key in KV
|
// setValue sets value for the given key in KV.
|
||||||
func (h *Headscale) setValue(key string, value string) error {
|
func (h *Headscale) setValue(key string, value string) error {
|
||||||
kv := KV{
|
keyValue := KV{
|
||||||
Key: key,
|
Key: key,
|
||||||
Value: value,
|
Value: value,
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err := h.getValue(key)
|
if _, err := h.getValue(key); err == nil {
|
||||||
if err == nil {
|
h.db.Model(&keyValue).Where("key = ?", key).Update("value", value)
|
||||||
h.db.Model(&kv).Where("key = ?", key).Update("value", value)
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
h.db.Create(kv)
|
if err := h.db.Create(keyValue).Error; err != nil {
|
||||||
|
return fmt.Errorf("failed to create key value pair in the database: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *Headscale) pingDB(ctx context.Context) error {
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, time.Second)
|
||||||
|
defer cancel()
|
||||||
|
db, err := h.db.DB()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return db.PingContext(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 IPPrefix netip.Prefix
|
||||||
|
|
||||||
|
func (i *IPPrefix) Scan(destination interface{}) error {
|
||||||
|
switch value := destination.(type) {
|
||||||
|
case string:
|
||||||
|
prefix, err := netip.ParsePrefix(value)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
*i = IPPrefix(prefix)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("%w: unexpected data type %T", ErrCannotParsePrefix, destination)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Value return json value, implement driver.Valuer interface.
|
||||||
|
func (i IPPrefix) Value() (driver.Value, error) {
|
||||||
|
prefixStr := netip.Prefix(i).String()
|
||||||
|
|
||||||
|
return prefixStr, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type IPPrefixes []netip.Prefix
|
||||||
|
|
||||||
|
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
|
||||||
|
derpport: 0
|
||||||
157
derp.go
Normal file
157
derp.go
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
package headscale
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/rs/zerolog/log"
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
"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, http.MethodGet, 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 := io.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)
|
||||||
|
if h.cfg.DERP.ServerEnabled {
|
||||||
|
h.DERPMap.Regions[h.DERPServer.region.RegionID] = &h.DERPServer.region
|
||||||
|
}
|
||||||
|
|
||||||
|
h.setLastStateChangeToNow()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user