mirror of
https://github.com/fosrl/newt.git
synced 2026-07-11 09:24:53 -05:00
Compare commits
620 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0da8a29a61 | ||
|
|
a1218ab67a | ||
|
|
bb84762b16 | ||
|
|
86155072de | ||
|
|
4a9a4c4eec | ||
|
|
21c744fe84 | ||
|
|
146e7835eb | ||
|
|
6aa94c0c2a | ||
|
|
542c70b326 | ||
|
|
663e98af60 | ||
|
|
901ec71baf | ||
|
|
9bc0204f57 | ||
|
|
1e77b09e3b | ||
|
|
74fd3f3aa3 | ||
|
|
e8dc19a62b | ||
|
|
9ff32b8a8b | ||
|
|
9edaac9c11 | ||
|
|
ced87b1d5e | ||
|
|
3aaebe64fb | ||
|
|
27f7ca6bb9 | ||
|
|
5090907307 | ||
|
|
a6533b3fa0 | ||
|
|
57aa2e2e2c | ||
|
|
5724c516dc | ||
|
|
b33c3b8849 | ||
|
|
8e19e475bf | ||
|
|
9e92c42876 | ||
|
|
66c72bbe2e | ||
|
|
ffd26f9a6d | ||
|
|
7610aa40bf | ||
|
|
bf33a66043 | ||
|
|
23caf57bf4 | ||
|
|
df3aa60cf5 | ||
|
|
5c43db466a | ||
|
|
cc663f1636 | ||
|
|
1a67ff30c2 | ||
|
|
bfd61ca511 | ||
|
|
294f99e024 | ||
|
|
af2ecf486a | ||
|
|
efd6743ce4 | ||
|
|
a0d2bb999a | ||
|
|
5d889fbc09 | ||
|
|
1a7cf06ff8 | ||
|
|
35a334c842 | ||
|
|
c8e5112a2a | ||
|
|
8bfb4659c0 | ||
|
|
309f9caad2 | ||
|
|
26de268466 | ||
|
|
0f927a37ab | ||
|
|
e8961c5de5 | ||
|
|
9bb8eaeadb | ||
|
|
d3d10d02e8 | ||
|
|
d133d69cb9 | ||
|
|
50be4f617e | ||
|
|
be1cd190e7 | ||
|
|
5c9d13bcca | ||
|
|
dc2e23380a | ||
|
|
3d2b73d417 | ||
|
|
12776d65c1 | ||
|
|
0569525743 | ||
|
|
342af9e42d | ||
|
|
092535441e | ||
|
|
5848c8d4b4 | ||
|
|
6becf0f719 | ||
|
|
47c646bc33 | ||
|
|
4d8d00241d | ||
|
|
31f899588f | ||
|
|
0104fb9b2d | ||
|
|
6dd9c4b0d1 | ||
|
|
e4daa620c7 | ||
|
|
7e1e3408d5 | ||
|
|
d7c3c38d24 | ||
|
|
27e471942e | ||
|
|
f5f2ba38d7 | ||
|
|
8cf3942366 | ||
|
|
f7bb240c74 | ||
|
|
cbd17ff249 | ||
|
|
b7f2445cfd | ||
|
|
88d954fc64 | ||
|
|
9b2d1f2a10 | ||
|
|
caa5a6a476 | ||
|
|
74183952fb | ||
|
|
05fc12f66e | ||
|
|
56cc225bd3 | ||
|
|
fee7fbe20a | ||
|
|
bc6661faa5 | ||
|
|
db6cabc6d7 | ||
|
|
3f32c9e8ef | ||
|
|
cd4782265a | ||
|
|
184bfb12d6 | ||
|
|
2e02c9b7a9 | ||
|
|
5c329be1f3 | ||
|
|
732e788c66 | ||
|
|
aa42b3623d | ||
|
|
4f42560e26 | ||
|
|
c2187de482 | ||
|
|
5ced7d6909 | ||
|
|
4e6e79ad21 | ||
|
|
abe6e2e400 | ||
|
|
f432a17c16 | ||
|
|
6f96169ff1 | ||
|
|
575942c4be | ||
|
|
16864fc1d7 | ||
|
|
f925c681d2 | ||
|
|
e01b0ae9c7 | ||
|
|
f4d071fe27 | ||
|
|
8d82460a76 | ||
|
|
5208117c56 | ||
|
|
381f5a619c | ||
|
|
b6f13a1b55 | ||
|
|
cdaf4f7898 | ||
|
|
d4a5ac8682 | ||
|
|
1057013b50 | ||
|
|
fc4b375bf1 | ||
|
|
baca04ee58 | ||
|
|
b43572dd8d | ||
|
|
69019d5655 | ||
|
|
0f57985b6f | ||
|
|
212bdf765a | ||
|
|
b045a0f5d4 | ||
|
|
a2683eb385 | ||
|
|
d3722c2519 | ||
|
|
8fda35db4f | ||
|
|
de4353f2e6 | ||
|
|
b398f531f0 | ||
|
|
8161fa6626 | ||
|
|
24dfb3a8a2 | ||
|
|
13448f76aa | ||
|
|
d4ebb3e2af | ||
|
|
bf029b7bb2 | ||
|
|
745d2dbc7e | ||
|
|
c7b01288e0 | ||
|
|
539e595c48 | ||
|
|
ef03b4566d | ||
|
|
836144aebf | ||
|
|
a1df3d7ff0 | ||
|
|
d68a13ea1f | ||
|
|
d7741df514 | ||
|
|
8e188933a2 | ||
|
|
a13c7c6e65 | ||
|
|
bc44ca1aba | ||
|
|
a76089db98 | ||
|
|
627ec2fdbc | ||
|
|
44ca592a5c | ||
|
|
accac75a53 | ||
|
|
768415f90b | ||
|
|
e1edbcea07 | ||
|
|
da9825d030 | ||
|
|
afdb1fc977 | ||
|
|
392e4c83bf | ||
|
|
1bd1133ac2 | ||
|
|
a85454e770 | ||
|
|
fac0f5b197 | ||
|
|
068145c539 | ||
|
|
91a035f4ab | ||
|
|
e68b65683f | ||
|
|
7d6825132b | ||
|
|
beaf386615 | ||
|
|
e474866f84 | ||
|
|
7920295b8c | ||
|
|
6371e980d2 | ||
|
|
bf7882eacc | ||
|
|
86a7d79092 | ||
|
|
1c22555bd4 | ||
|
|
30ade8102d | ||
|
|
15ea631b96 | ||
|
|
4e854b5f96 | ||
|
|
287eef0f44 | ||
|
|
f982e6b629 | ||
|
|
6c6ba45024 | ||
|
|
3f84354e7f | ||
|
|
039ae07b7b | ||
|
|
ec399e9d4d | ||
|
|
19f143fc6a | ||
|
|
6d5e099681 | ||
|
|
d6ea37f45c | ||
|
|
de3d358fe9 | ||
|
|
4bcd2ffc59 | ||
|
|
06e175ac9a | ||
|
|
6e79eef224 | ||
|
|
23dc230654 | ||
|
|
9b8bc7b66e | ||
|
|
c5ae4a8f11 | ||
|
|
b2600b0dab | ||
|
|
1656141599 | ||
|
|
1bf89a2cc9 | ||
|
|
555e1ddc7c | ||
|
|
514c94519e | ||
|
|
1a3eaedfa5 | ||
|
|
01e2ba31b7 | ||
|
|
9738565a3a | ||
|
|
c42a606bbd | ||
|
|
5977667291 | ||
|
|
556be90b7e | ||
|
|
5d04be92f7 | ||
|
|
b7af49d759 | ||
|
|
00a5fa1f37 | ||
|
|
d256d6c746 | ||
|
|
2cc957d55f | ||
|
|
d98eaa88b3 | ||
|
|
5b884042cd | ||
|
|
2265b61381 | ||
|
|
60dac98514 | ||
|
|
759e4c5bac | ||
|
|
8609be130e | ||
|
|
e06b8de0a7 | ||
|
|
0af6fb8fef | ||
|
|
9526768dfe | ||
|
|
051ab6ca9d | ||
|
|
50fbfdc262 | ||
|
|
5fb60baa14 | ||
|
|
ea2e166973 | ||
|
|
a8549f32e9 | ||
|
|
ad6bbd47ad | ||
|
|
f6b7aaedfd | ||
|
|
2055b773fd | ||
|
|
1c9c98e2f6 | ||
|
|
9c57677493 | ||
|
|
ff825a51dd | ||
|
|
cdfc5733f0 | ||
|
|
cadbb50bdf | ||
|
|
4ac33c824b | ||
|
|
d91228f636 | ||
|
|
6c3b85bb9a | ||
|
|
77d99f1722 | ||
|
|
43e1341352 | ||
|
|
daa1a90e05 | ||
|
|
3739c237c7 | ||
|
|
ddde1758e5 | ||
|
|
dca29781f3 | ||
|
|
91bfd69179 | ||
|
|
060d876429 | ||
|
|
69952efe89 | ||
|
|
66949ca047 | ||
|
|
8c12db6dff | ||
|
|
b84d465763 | ||
|
|
a62567997d | ||
|
|
9bb4bbccb8 | ||
|
|
c3fad797e5 | ||
|
|
0168b4796e | ||
|
|
6c05d76c88 | ||
|
|
a701add824 | ||
|
|
d754cea397 | ||
|
|
31d52ad3ff | ||
|
|
e1ee4dc8f2 | ||
|
|
f9b6f36b4f | ||
|
|
0e961761b8 | ||
|
|
baf1b9b972 | ||
|
|
f078136b5a | ||
|
|
ca341a8bb0 | ||
|
|
80ae03997a | ||
|
|
5c94789d9a | ||
|
|
6c65cc8e5e | ||
|
|
a21a8e90fa | ||
|
|
3d5335f2cb | ||
|
|
94788edce3 | ||
|
|
2bbe037544 | ||
|
|
9b015e9f7c | ||
|
|
3305f711b9 | ||
|
|
ff7fe1275b | ||
|
|
1cbf41e094 | ||
|
|
9bc35433ef | ||
|
|
b8349aab4e | ||
|
|
3f29a553ae | ||
|
|
745045f619 | ||
|
|
3783a12055 | ||
|
|
a9b84c8c09 | ||
|
|
5c5ef4c7e6 | ||
|
|
6e9249e664 | ||
|
|
55be2a52a5 | ||
|
|
058330d41b | ||
|
|
1cf75b00ff | ||
|
|
5e7b970115 | ||
|
|
dc180abba9 | ||
|
|
004bb9b12d | ||
|
|
0637360b31 | ||
|
|
d5e0771094 | ||
|
|
1dcb68d694 | ||
|
|
865ac4b682 | ||
|
|
de5627b0b7 | ||
|
|
44470abd54 | ||
|
|
4bb0537c39 | ||
|
|
92fb96f9bd | ||
|
|
b68b7fe49d | ||
|
|
1da424bb20 | ||
|
|
22e5104a41 | ||
|
|
b96adeaa5b | ||
|
|
533e0b9ca7 | ||
|
|
bd86abe8d5 | ||
|
|
d978b27ebc | ||
|
|
cdfcf49d89 | ||
|
|
2fb4bf09ea | ||
|
|
dddae547f5 | ||
|
|
73a14f5fa1 | ||
|
|
67d5217379 | ||
|
|
9f1f1328f6 | ||
|
|
30da7eaa8b | ||
|
|
0fca3457c3 | ||
|
|
1271e8235e | ||
|
|
24c6edf3e0 | ||
|
|
1875c987fe | ||
|
|
7cb1f7e2c2 | ||
|
|
3f4f4fa15c | ||
|
|
bf33a3d81f | ||
|
|
21ffc0ff4b | ||
|
|
13de05eec6 | ||
|
|
0e76b77adc | ||
|
|
c604f46065 | ||
|
|
f02e29f4dd | ||
|
|
6d79856895 | ||
|
|
bbece243dd | ||
|
|
6948066ae4 | ||
|
|
3bcafbf07a | ||
|
|
87e2eb33db | ||
|
|
5ce3f4502d | ||
|
|
e5e733123b | ||
|
|
f417ee32fb | ||
|
|
37c96d0b3e | ||
|
|
78dc39e153 | ||
|
|
71485743ad | ||
|
|
458912e5be | ||
|
|
2bc91d6c68 | ||
|
|
95c3efc365 | ||
|
|
72a9e111dc | ||
|
|
3c86edf0d5 | ||
|
|
32b1b817ac | ||
|
|
02949be245 | ||
|
|
6d51cbf0c0 | ||
|
|
4dbf200cca | ||
|
|
d8b4fb4acb | ||
|
|
ac691517ae | ||
|
|
8a45f6fd63 | ||
|
|
7f650bbfdf | ||
|
|
15b40b0f24 | ||
|
|
e27e6fbce8 | ||
|
|
f9fb13a0d7 | ||
|
|
8db50d94c0 | ||
|
|
09568c1aaf | ||
|
|
c7d656214f | ||
|
|
d981a82b1c | ||
|
|
5dd5a56379 | ||
|
|
8c4d6e2e0a | ||
|
|
284f1ce627 | ||
|
|
cd466ac43f | ||
|
|
2256d1f041 | ||
|
|
40ca839771 | ||
|
|
01ec6a0ce0 | ||
|
|
d04f6cf702 | ||
|
|
cdaff27964 | ||
|
|
de96be810b | ||
|
|
ba43083f04 | ||
|
|
5196effdb8 | ||
|
|
d6edd6ca01 | ||
|
|
1b1323b553 | ||
|
|
bb95d10e86 | ||
|
|
da04746781 | ||
|
|
a38e0b3e98 | ||
|
|
6ced7b5af0 | ||
|
|
61b9615aea | ||
|
|
39f5782583 | ||
|
|
025c94e586 | ||
|
|
75e666c396 | ||
|
|
82a999eb87 | ||
|
|
921e72f628 | ||
|
|
46b33fdca6 | ||
|
|
9caa9fa31e | ||
|
|
dbbea6b34c | ||
|
|
491180c6a1 | ||
|
|
f49a276259 | ||
|
|
c71c6e0b1a | ||
|
|
972c9a9760 | ||
|
|
8f7ee2a8dc | ||
|
|
a737c3e8de | ||
|
|
1ba10c1b68 | ||
|
|
b1f2fe8283 | ||
|
|
a1fdb06add | ||
|
|
25d5fab02b | ||
|
|
2c8755f346 | ||
|
|
348cac66c8 | ||
|
|
6226a262d6 | ||
|
|
5b70feb6a5 | ||
|
|
0ec18d6655 | ||
|
|
7d60240572 | ||
|
|
ee3e7d1442 | ||
|
|
527321a415 | ||
|
|
ff07692248 | ||
|
|
8d3ae5afd7 | ||
|
|
ed99dce7e0 | ||
|
|
f1e07272bd | ||
|
|
a1a3d63fcf | ||
|
|
2a273dc435 | ||
|
|
ec05686523 | ||
|
|
915e7e44d1 | ||
|
|
a729b91ac3 | ||
|
|
ddc37658df | ||
|
|
7c780f7a4f | ||
|
|
6b1c1ed077 | ||
|
|
7a07437b22 | ||
|
|
d63d8d6f5e | ||
|
|
bda1d04f67 | ||
|
|
7f8ee37c7f | ||
|
|
6d2073a478 | ||
|
|
6048f244f1 | ||
|
|
9fec22a53b | ||
|
|
c086e69dd0 | ||
|
|
c729ab5fc6 | ||
|
|
552617cbb5 | ||
|
|
b383cec0b0 | ||
|
|
fb110ba2a1 | ||
|
|
f287888480 | ||
|
|
348b8f6b94 | ||
|
|
71c5bf7e65 | ||
|
|
dda0b414cc | ||
|
|
8f224e2a45 | ||
|
|
90243cd6c6 | ||
|
|
9b79af10ed | ||
|
|
31b1ffcbe9 | ||
|
|
f1c4e1db71 | ||
|
|
72a61d0933 | ||
|
|
e489a2cc66 | ||
|
|
4e648af8e9 | ||
|
|
5d891225de | ||
|
|
9864965381 | ||
|
|
75f6362a90 | ||
|
|
30907188fb | ||
|
|
5f11df8df2 | ||
|
|
7eea6dd335 | ||
|
|
9dc5a3d91c | ||
|
|
1881309148 | ||
|
|
aff928e60f | ||
|
|
f6e7bfe8ea | ||
|
|
60873f0a4f | ||
|
|
50bb81981b | ||
|
|
4ced99fa3f | ||
|
|
9bd96ac540 | ||
|
|
c673743692 | ||
|
|
a08a3b9665 | ||
|
|
0fc13be413 | ||
|
|
92cedd00b3 | ||
|
|
8b0cc36554 | ||
|
|
ba9ca9f097 | ||
|
|
8b4a88937c | ||
|
|
58412a7a61 | ||
|
|
2675b812aa | ||
|
|
217a9346c6 | ||
|
|
eda8073bce | ||
|
|
2969f9d2d6 | ||
|
|
07b7025a24 | ||
|
|
502ebfc362 | ||
|
|
288413fd15 | ||
|
|
0ba44206b1 | ||
|
|
3f8dcd8f22 | ||
|
|
c5c0143013 | ||
|
|
87ac5c97e3 | ||
|
|
e2238c3cc8 | ||
|
|
58a67328d3 | ||
|
|
002fdc4d3f | ||
|
|
9a1fa2c19f | ||
|
|
a6797172ef | ||
|
|
d373de7fa1 | ||
|
|
f876bad632 | ||
|
|
54b096e6a7 | ||
|
|
10720afd31 | ||
|
|
0b37f20d5d | ||
|
|
aa6e54f383 | ||
|
|
30f8eb9785 | ||
|
|
e765d9c774 | ||
|
|
3ae4ac23ef | ||
|
|
6a98b90b01 | ||
|
|
e0ce9d4e48 | ||
|
|
5914c9ed33 | ||
|
|
109bda961f | ||
|
|
c2a93134b1 | ||
|
|
100d8e6afe | ||
|
|
04f2048a0a | ||
|
|
04de5ef8ba | ||
|
|
e77601cccc | ||
|
|
e9752f868e | ||
|
|
866afaf749 | ||
|
|
a12ae17a66 | ||
|
|
e0cba2e5c6 | ||
|
|
79f3db6fb6 | ||
|
|
009b4cf425 | ||
|
|
9c28d75155 | ||
|
|
bad244d0ea | ||
|
|
d013dc0543 | ||
|
|
0047b54e94 | ||
|
|
f0c8d2c7c7 | ||
|
|
28b6865f73 | ||
|
|
d52f89f629 | ||
|
|
289cce3a22 | ||
|
|
e8612c7e6b | ||
|
|
6820f8d23e | ||
|
|
151d0e38e6 | ||
|
|
a9d8ec0b1e | ||
|
|
e9dbfb239b | ||
|
|
a79dccc0e4 | ||
|
|
42dfb6b3d8 | ||
|
|
3ccd755d55 | ||
|
|
a0f0b674e8 | ||
|
|
9e675121d3 | ||
|
|
45d17da570 | ||
|
|
dfba35f8bb | ||
|
|
9e73aab21d | ||
|
|
e1ddad006a | ||
|
|
29567d6e0b | ||
|
|
47321ea9ad | ||
|
|
abfc9d8efc | ||
|
|
c6929621e7 | ||
|
|
46993203a3 | ||
|
|
8306084354 | ||
|
|
02c1e2b7d0 | ||
|
|
ae7e2a1055 | ||
|
|
88f1335cff | ||
|
|
8bf9c9795b | ||
|
|
5d343cd420 | ||
|
|
d1473b7e22 | ||
|
|
2efbd7dd6a | ||
|
|
82a3a39a1f | ||
|
|
df09193834 | ||
|
|
b2fe4e3b03 | ||
|
|
e14d53087f | ||
|
|
3583270f73 | ||
|
|
f5be05c55a | ||
|
|
d09e3fbd60 | ||
|
|
493831b5f0 | ||
|
|
9fc692c090 | ||
|
|
ccb7008579 | ||
|
|
f17dbe1fef | ||
|
|
27561f52ca | ||
|
|
499ebcd928 | ||
|
|
40dfab31a5 | ||
|
|
56377ec87e | ||
|
|
008be54c55 | ||
|
|
64c22a94a4 | ||
|
|
468c93c581 | ||
|
|
c53b859cda | ||
|
|
6cd824baf2 | ||
|
|
d8c5182acd | ||
|
|
c8c4666d63 | ||
|
|
f1fcc13e66 | ||
|
|
52bbc2fe31 | ||
|
|
b5ee12f84a | ||
|
|
510e78437c | ||
|
|
e14cffce1c | ||
|
|
629a92ee81 | ||
|
|
56df75544d | ||
|
|
5b2e743470 | ||
|
|
b5025c142f | ||
|
|
cd86e6b6de | ||
|
|
230c34e4e0 | ||
|
|
a038ce1458 | ||
|
|
cd83efd365 | ||
|
|
702f39e870 | ||
|
|
02b7ea51af | ||
|
|
e8421364fc | ||
|
|
7264bb7001 | ||
|
|
86e262ac1e | ||
|
|
dcacc03e96 | ||
|
|
6f4469a5a4 | ||
|
|
663e28329b | ||
|
|
f513f97fc3 | ||
|
|
ce4f3e4cdf | ||
|
|
58a74fce6f | ||
|
|
fc965abbc4 | ||
|
|
b881808cae | ||
|
|
6160e4c8a6 | ||
|
|
4d343e3541 | ||
|
|
71d1bbaaf2 | ||
|
|
9eb8e5122a | ||
|
|
c593e2aa97 | ||
|
|
c3483ded8f | ||
|
|
b1cfd3ba02 | ||
|
|
2d9b761de9 | ||
|
|
53397663ef | ||
|
|
221d5862fb | ||
|
|
e4bdbbec7c | ||
|
|
07bd283604 | ||
|
|
b7d4ea0c84 | ||
|
|
d10c5e0366 | ||
|
|
61a9097baf | ||
|
|
a88d25f369 | ||
|
|
c2a326c70a | ||
|
|
be56550da4 | ||
|
|
a896291831 | ||
|
|
9348842e2c | ||
|
|
ec8fc20438 | ||
|
|
a39d056725 | ||
|
|
450fc6c20f | ||
|
|
837f749cdb | ||
|
|
63aea704bd | ||
|
|
c1a2a3208c | ||
|
|
ac67df63fa | ||
|
|
e642983b88 | ||
|
|
e2dd965654 | ||
|
|
227631665e | ||
|
|
ca3ffa00ed | ||
|
|
6d3938e14e | ||
|
|
37191924ee | ||
|
|
a14f70dbaa | ||
|
|
bb1318278a | ||
|
|
1c75eb3bee | ||
|
|
4b64b04603 | ||
|
|
b397016da8 | ||
|
|
850c230c4a | ||
|
|
c82b84194f | ||
|
|
50df49e556 | ||
|
|
5f9c041c6b | ||
|
|
95d4cb2758 | ||
|
|
bbea9a91da | ||
|
|
8d4d8b91b9 | ||
|
|
d0e220511a | ||
|
|
22f44c860a | ||
|
|
a5f4d5fdf6 | ||
|
|
ce6d340a8d | ||
|
|
eaf812a2a7 | ||
|
|
494e30704b | ||
|
|
175718a48e | ||
|
|
6a146ed371 | ||
|
|
027d9a059f |
@@ -1,6 +1,6 @@
|
||||
.gitignore
|
||||
.dockerignore
|
||||
bin/
|
||||
newt
|
||||
*.json
|
||||
README.md
|
||||
Makefile
|
||||
|
||||
5
.github/ISSUE_TEMPLATE/1.bug_report.yml
vendored
5
.github/ISSUE_TEMPLATE/1.bug_report.yml
vendored
@@ -14,13 +14,12 @@ body:
|
||||
label: Environment
|
||||
description: Please fill out the relevant details below for your environment.
|
||||
value: |
|
||||
- OS Type & Version:
|
||||
- OS Type & Version: (e.g., Ubuntu 22.04)
|
||||
- Pangolin Version:
|
||||
- Edition (Community or Enterprise):
|
||||
- Gerbil Version:
|
||||
- Traefik Version:
|
||||
- Newt Version:
|
||||
- Client Version:
|
||||
- Olm Version: (if applicable)
|
||||
validations:
|
||||
required: true
|
||||
|
||||
|
||||
36
.github/dependabot.yml
vendored
36
.github/dependabot.yml
vendored
@@ -1,32 +1,40 @@
|
||||
version: 2
|
||||
|
||||
updates:
|
||||
- package-ecosystem: "gomod"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "daily"
|
||||
open-pull-requests-limit: 1
|
||||
groups:
|
||||
go-dependencies:
|
||||
patterns:
|
||||
- "*"
|
||||
dev-patch-updates:
|
||||
dependency-type: "development"
|
||||
update-types:
|
||||
- "patch"
|
||||
dev-minor-updates:
|
||||
dependency-type: "development"
|
||||
update-types:
|
||||
- "minor"
|
||||
prod-patch-updates:
|
||||
dependency-type: "production"
|
||||
update-types:
|
||||
- "patch"
|
||||
prod-minor-updates:
|
||||
dependency-type: "production"
|
||||
update-types:
|
||||
- "minor"
|
||||
|
||||
- package-ecosystem: "docker"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "daily"
|
||||
open-pull-requests-limit: 1
|
||||
groups:
|
||||
docker-dependencies:
|
||||
patterns:
|
||||
- "*"
|
||||
patch-updates:
|
||||
update-types:
|
||||
- "patch"
|
||||
minor-updates:
|
||||
update-types:
|
||||
- "minor"
|
||||
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
open-pull-requests-limit: 1
|
||||
groups:
|
||||
github-actions-dependencies:
|
||||
patterns:
|
||||
- "*"
|
||||
|
||||
83
.github/workflows/cicd.yml
vendored
83
.github/workflows/cicd.yml
vendored
@@ -68,7 +68,7 @@ jobs:
|
||||
echo "image_created=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Configure AWS credentials (OIDC)
|
||||
uses: aws-actions/configure-aws-credentials@254c19bd240aabef8777f48595e9d2d7b972184b # v6.2.1
|
||||
uses: aws-actions/configure-aws-credentials@8df5847569e6427dd6c4fb1cf565c83acfa8afa7 # v6.0.0
|
||||
with:
|
||||
role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/${{ secrets.AWS_ROLE_NAME }}
|
||||
role-duration-seconds: 3600
|
||||
@@ -95,7 +95,7 @@ jobs:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
@@ -158,7 +158,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
@@ -237,20 +237,20 @@ jobs:
|
||||
echo "Checked out $(git rev-parse --short HEAD) for tag ${TAG}"
|
||||
|
||||
#- name: Set up QEMU
|
||||
# uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4.2.0
|
||||
# uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0
|
||||
|
||||
#- name: Set up Docker Buildx
|
||||
# uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
|
||||
# uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@c99871dec2022cc055c062a10cc1a1310835ceb4 # v4.3.0
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||
with:
|
||||
registry: docker.io
|
||||
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }}
|
||||
|
||||
- name: Log in to GHCR
|
||||
uses: docker/login-action@c99871dec2022cc055c062a10cc1a1310835ceb4 # v4.3.0
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
@@ -264,12 +264,12 @@ jobs:
|
||||
echo "DOCKERHUB_IMAGE=${DOCKERHUB_IMAGE,,}" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
|
||||
|
||||
# Build ONLY amd64 and push arch-specific tag suffixes used later for manifest creation.
|
||||
- name: Build and push (amd64 -> *:amd64-TAG)
|
||||
id: build_amd
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
||||
uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
@@ -309,7 +309,7 @@ jobs:
|
||||
IMAGE_CREATED: ${{ needs.pre-run.outputs.image_created }}
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
@@ -368,14 +368,14 @@ jobs:
|
||||
echo "Checked out $(git rev-parse --short HEAD) for tag ${TAG}"
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@c99871dec2022cc055c062a10cc1a1310835ceb4 # v4.3.0
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||
with:
|
||||
registry: docker.io
|
||||
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }}
|
||||
|
||||
- name: Log in to GHCR
|
||||
uses: docker/login-action@c99871dec2022cc055c062a10cc1a1310835ceb4 # v4.3.0
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
@@ -389,12 +389,12 @@ jobs:
|
||||
echo "DOCKERHUB_IMAGE=${DOCKERHUB_IMAGE,,}" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
|
||||
|
||||
# Build ONLY arm64 and push arch-specific tag suffixes used later for manifest creation.
|
||||
- name: Build and push (arm64 -> *:arm64-TAG)
|
||||
id: build_arm
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
||||
uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
@@ -434,7 +434,7 @@ jobs:
|
||||
IMAGE_CREATED: ${{ needs.pre-run.outputs.image_created }}
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
@@ -483,14 +483,14 @@ jobs:
|
||||
echo "Checked out $(git rev-parse --short HEAD) for tag ${TAG}"
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@c99871dec2022cc055c062a10cc1a1310835ceb4 # v4.3.0
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||
with:
|
||||
registry: docker.io
|
||||
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }}
|
||||
|
||||
- name: Log in to GHCR
|
||||
uses: docker/login-action@c99871dec2022cc055c062a10cc1a1310835ceb4 # v4.3.0
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
@@ -504,14 +504,14 @@ jobs:
|
||||
echo "DOCKERHUB_IMAGE=${DOCKERHUB_IMAGE,,}" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4.2.0
|
||||
uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
|
||||
|
||||
- name: Build and push (arm/v7 -> *:armv7-TAG)
|
||||
id: build_armv7
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
||||
uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
@@ -556,14 +556,14 @@ jobs:
|
||||
#PUBLISH_MINOR: ${{ github.event_name == 'workflow_dispatch' && inputs.publish_minor || vars.PUBLISH_MINOR }}
|
||||
steps:
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@c99871dec2022cc055c062a10cc1a1310835ceb4 # v4.3.0
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||
with:
|
||||
registry: docker.io
|
||||
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }}
|
||||
|
||||
- name: Log in to GHCR
|
||||
uses: docker/login-action@c99871dec2022cc055c062a10cc1a1310835ceb4 # v4.3.0
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
@@ -577,7 +577,7 @@ jobs:
|
||||
echo "DOCKERHUB_IMAGE=${DOCKERHUB_IMAGE,,}" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Set up Docker Buildx (needed for imagetools)
|
||||
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
|
||||
|
||||
- name: Create & push multi-arch index (GHCR :TAG) via imagetools
|
||||
shell: bash
|
||||
@@ -642,7 +642,7 @@ jobs:
|
||||
IMAGE_CREATED: ${{ needs.pre-run.outputs.image_created }}
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
@@ -656,19 +656,19 @@ jobs:
|
||||
echo "Checked out $(git rev-parse --short HEAD) for tag ${TAG}"
|
||||
|
||||
- name: Install Go
|
||||
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
|
||||
uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6.2.0
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@c99871dec2022cc055c062a10cc1a1310835ceb4 # v4.3.0
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||
with:
|
||||
registry: docker.io
|
||||
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }}
|
||||
|
||||
- name: Log in to GHCR
|
||||
uses: docker/login-action@c99871dec2022cc055c062a10cc1a1310835ceb4 # v4.3.0
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
@@ -692,7 +692,7 @@ jobs:
|
||||
sudo apt-get install -y jq
|
||||
|
||||
- name: Set up Docker Buildx (needed for imagetools)
|
||||
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
|
||||
|
||||
- name: Resolve multi-arch digest refs (by TAG)
|
||||
shell: bash
|
||||
@@ -732,7 +732,7 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Attest build provenance (GHCR) (digest)
|
||||
uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1
|
||||
uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0
|
||||
with:
|
||||
subject-name: ${{ env.GHCR_IMAGE }}
|
||||
subject-digest: ${{ env.GHCR_DIGEST }}
|
||||
@@ -742,7 +742,7 @@ jobs:
|
||||
- name: Attest build provenance (Docker Hub)
|
||||
continue-on-error: true
|
||||
if: ${{ env.DH_DIGEST != '' }}
|
||||
uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1
|
||||
uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0
|
||||
with:
|
||||
subject-name: index.docker.io/${{ github.repository_owner }}/${{ github.event.repository.name }}
|
||||
subject-digest: ${{ env.DH_DIGEST }}
|
||||
@@ -750,9 +750,9 @@ jobs:
|
||||
show-summary: true
|
||||
|
||||
- name: Install cosign
|
||||
uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
|
||||
uses: sigstore/cosign-installer@cad07c2e89fa2edd6e2d7bab4c1aa38e53f76003 # v4.1.1
|
||||
with:
|
||||
cosign-release: v3.0.6
|
||||
cosign-release: "v3.0.2"
|
||||
|
||||
- name: Sanity check cosign private key
|
||||
env:
|
||||
@@ -897,29 +897,14 @@ jobs:
|
||||
set -euo pipefail
|
||||
make -j 10 go-build-release VERSION="${TAG}"
|
||||
|
||||
- name: Build Advantech packages
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
ADVANTECH_DIR="packages/advantech"
|
||||
|
||||
mkdir -p "${ADVANTECH_DIR}/bin"
|
||||
install -m 0755 "bin/newt_linux_arm64" "${ADVANTECH_DIR}/bin/newt_linux_arm64"
|
||||
install -m 0755 "bin/newt_linux_arm32" "${ADVANTECH_DIR}/bin/newt_linux_arm32"
|
||||
|
||||
for platform in v2 v2i v3 v4 v4i; do
|
||||
make -C "${ADVANTECH_DIR}" PLATFORM="${platform}"
|
||||
done
|
||||
|
||||
- name: Create GitHub Release (draft)
|
||||
uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b # v3.0.1
|
||||
uses: softprops/action-gh-release@153bb8e04406b158c6c84fc1615b65b24149a1fe # v2.6.1
|
||||
with:
|
||||
tag_name: ${{ env.TAG }}
|
||||
generate_release_notes: true
|
||||
prerelease: ${{ env.IS_RC == 'true' }}
|
||||
files: |
|
||||
bin/*
|
||||
packages/advantech/*.tgz
|
||||
fail_on_unmatched_files: true
|
||||
draft: true
|
||||
body: |
|
||||
@@ -940,7 +925,7 @@ jobs:
|
||||
permissions: write-all
|
||||
steps:
|
||||
- name: Configure AWS credentials (OIDC)
|
||||
uses: aws-actions/configure-aws-credentials@254c19bd240aabef8777f48595e9d2d7b972184b # v6.2.1
|
||||
uses: aws-actions/configure-aws-credentials@8df5847569e6427dd6c4fb1cf565c83acfa8afa7 # v6.0.0
|
||||
with:
|
||||
role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/${{ secrets.AWS_ROLE_NAME }}
|
||||
role-duration-seconds: 3600
|
||||
|
||||
2
.github/workflows/mirror.yaml
vendored
2
.github/workflows/mirror.yaml
vendored
@@ -23,7 +23,7 @@ jobs:
|
||||
skopeo --version
|
||||
|
||||
- name: Install cosign
|
||||
uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
|
||||
uses: sigstore/cosign-installer@cad07c2e89fa2edd6e2d7bab4c1aa38e53f76003 # v4.1.1
|
||||
|
||||
- name: Input check
|
||||
run: |
|
||||
|
||||
2
.github/workflows/nix-build.yml
vendored
2
.github/workflows/nix-build.yml
vendored
@@ -13,7 +13,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Install Nix
|
||||
uses: DeterminateSystems/nix-installer-action@main
|
||||
|
||||
@@ -16,7 +16,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ github.head_ref }}
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
2
.github/workflows/stale-bot.yml
vendored
2
.github/workflows/stale-bot.yml
vendored
@@ -14,7 +14,7 @@ jobs:
|
||||
stale:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10.3.0
|
||||
- uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0
|
||||
with:
|
||||
days-before-stale: 14
|
||||
days-before-close: 14
|
||||
|
||||
20
.github/workflows/test.yml
vendored
20
.github/workflows/test.yml
vendored
@@ -10,20 +10,6 @@ on:
|
||||
- dev
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Run Go tests
|
||||
run: make test
|
||||
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
@@ -42,12 +28,12 @@ jobs:
|
||||
- go-build-release-windows-amd64
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
|
||||
uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6.2.0
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
go-version: 1.25
|
||||
|
||||
- name: Build targets via `make`
|
||||
run: make ${{ matrix.target }}
|
||||
|
||||
@@ -27,10 +27,6 @@ RUN apk --no-cache add ca-certificates tzdata iputils
|
||||
COPY --from=builder /newt /usr/local/bin/
|
||||
COPY entrypoint.sh /
|
||||
|
||||
# Marks this as an official Fossorial container image.
|
||||
# Auto-update is disabled in official images — update by pulling a new image tag.
|
||||
ENV NEWT_SYSTEM_SUBSTRATE="CONTAINER"
|
||||
|
||||
# Admin/metrics endpoint (Prometheus scrape)
|
||||
EXPOSE 2112
|
||||
|
||||
|
||||
27
Makefile
27
Makefile
@@ -1,4 +1,4 @@
|
||||
.PHONY: all local test docker-build docker-build-release
|
||||
.PHONY: all local docker-build docker-build-release
|
||||
|
||||
all: local
|
||||
|
||||
@@ -6,10 +6,7 @@ VERSION ?= dev
|
||||
LDFLAGS = -X main.newtVersion=$(VERSION)
|
||||
|
||||
local:
|
||||
CGO_ENABLED=0 go build -ldflags "$(LDFLAGS) -X main.newtPlatform=$(shell go env GOOS)_$(shell go env GOARCH)" -o ./bin/newt
|
||||
|
||||
test:
|
||||
go test ./...
|
||||
CGO_ENABLED=0 go build -ldflags "$(LDFLAGS)" -o ./bin/newt
|
||||
|
||||
docker-build:
|
||||
docker build -t fosrl/newt:latest .
|
||||
@@ -46,31 +43,31 @@ go-build-release: \
|
||||
go-build-release-freebsd-arm64
|
||||
|
||||
go-build-release-linux-arm64:
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags "$(LDFLAGS) -X main.newtPlatform=linux_arm64" -o bin/newt_linux_arm64
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags "$(LDFLAGS)" -o bin/newt_linux_arm64
|
||||
|
||||
go-build-release-linux-arm32-v7:
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH=arm GOARM=7 go build -ldflags "$(LDFLAGS) -X main.newtPlatform=linux_arm32" -o bin/newt_linux_arm32
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH=arm GOARM=7 go build -ldflags "$(LDFLAGS)" -o bin/newt_linux_arm32
|
||||
|
||||
go-build-release-linux-arm32-v6:
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH=arm GOARM=6 go build -ldflags "$(LDFLAGS) -X main.newtPlatform=linux_arm32v6" -o bin/newt_linux_arm32v6
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH=arm GOARM=6 go build -ldflags "$(LDFLAGS)" -o bin/newt_linux_arm32v6
|
||||
|
||||
go-build-release-linux-amd64:
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags "$(LDFLAGS) -X main.newtPlatform=linux_amd64" -o bin/newt_linux_amd64
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags "$(LDFLAGS)" -o bin/newt_linux_amd64
|
||||
|
||||
go-build-release-linux-riscv64:
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH=riscv64 go build -ldflags "$(LDFLAGS) -X main.newtPlatform=linux_riscv64" -o bin/newt_linux_riscv64
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH=riscv64 go build -ldflags "$(LDFLAGS)" -o bin/newt_linux_riscv64
|
||||
|
||||
go-build-release-darwin-arm64:
|
||||
CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -ldflags "$(LDFLAGS) -X main.newtPlatform=darwin_arm64" -o bin/newt_darwin_arm64
|
||||
CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -ldflags "$(LDFLAGS)" -o bin/newt_darwin_arm64
|
||||
|
||||
go-build-release-darwin-amd64:
|
||||
CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build -ldflags "$(LDFLAGS) -X main.newtPlatform=darwin_amd64" -o bin/newt_darwin_amd64
|
||||
CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build -ldflags "$(LDFLAGS)" -o bin/newt_darwin_amd64
|
||||
|
||||
go-build-release-windows-amd64:
|
||||
CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -ldflags "$(LDFLAGS) -X main.newtPlatform=windows_amd64" -o bin/newt_windows_amd64.exe
|
||||
CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -ldflags "$(LDFLAGS)" -o bin/newt_windows_amd64.exe
|
||||
|
||||
go-build-release-freebsd-amd64:
|
||||
CGO_ENABLED=0 GOOS=freebsd GOARCH=amd64 go build -ldflags "$(LDFLAGS) -X main.newtPlatform=freebsd_amd64" -o bin/newt_freebsd_amd64
|
||||
CGO_ENABLED=0 GOOS=freebsd GOARCH=amd64 go build -ldflags "$(LDFLAGS)" -o bin/newt_freebsd_amd64
|
||||
|
||||
go-build-release-freebsd-arm64:
|
||||
CGO_ENABLED=0 GOOS=freebsd GOARCH=arm64 go build -ldflags "$(LDFLAGS) -X main.newtPlatform=freebsd_arm64" -o bin/newt_freebsd_arm64
|
||||
CGO_ENABLED=0 GOOS=freebsd GOARCH=arm64 go build -ldflags "$(LDFLAGS)" -o bin/newt_freebsd_arm64
|
||||
@@ -1,10 +1,14 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"runtime"
|
||||
|
||||
"github.com/fosrl/newt/authdaemon"
|
||||
"github.com/fosrl/newt/logger"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -12,6 +16,64 @@ const (
|
||||
defaultCACertPath = "/etc/ssh/ca.pem"
|
||||
)
|
||||
|
||||
var (
|
||||
errPresharedKeyRequired = errors.New("auth-daemon-key is required when --auth-daemon is enabled")
|
||||
errRootRequired = errors.New("auth-daemon must be run as root (use sudo)")
|
||||
authDaemonServer *authdaemon.Server // Global auth daemon server instance
|
||||
)
|
||||
|
||||
// startAuthDaemon initializes and starts the auth daemon in the background.
|
||||
// It validates requirements (Linux, root, preshared key) and starts the server
|
||||
// in a goroutine so it runs alongside normal newt operation.
|
||||
func startAuthDaemon(ctx context.Context) error {
|
||||
// Validation
|
||||
if runtime.GOOS != "linux" {
|
||||
return fmt.Errorf("auth-daemon is only supported on Linux, not %s", runtime.GOOS)
|
||||
}
|
||||
if os.Geteuid() != 0 {
|
||||
return errRootRequired
|
||||
}
|
||||
|
||||
// Use defaults if not set
|
||||
principalsFile := authDaemonPrincipalsFile
|
||||
if principalsFile == "" {
|
||||
principalsFile = defaultPrincipalsPath
|
||||
}
|
||||
caCertPath := authDaemonCACertPath
|
||||
if caCertPath == "" {
|
||||
caCertPath = defaultCACertPath
|
||||
}
|
||||
|
||||
// Create auth daemon server
|
||||
cfg := authdaemon.Config{
|
||||
DisableHTTPS: true, // We run without HTTP server in newt
|
||||
PresharedKey: "this-key-is-not-used", // Not used in embedded mode, but set to non-empty to satisfy validation
|
||||
PrincipalsFilePath: principalsFile,
|
||||
CACertPath: caCertPath,
|
||||
Force: true,
|
||||
GenerateRandomPassword: authDaemonGenerateRandomPassword,
|
||||
}
|
||||
|
||||
srv, err := authdaemon.NewServer(cfg)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create auth daemon server: %w", err)
|
||||
}
|
||||
|
||||
authDaemonServer = srv
|
||||
|
||||
// Start the auth daemon in a goroutine so it runs alongside newt
|
||||
go func() {
|
||||
logger.Info("Auth daemon starting (native mode, no HTTP server)")
|
||||
if err := srv.Run(ctx); err != nil {
|
||||
logger.Error("Auth daemon error: %v", err)
|
||||
}
|
||||
logger.Info("Auth daemon stopped")
|
||||
}()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// runPrincipalsCmd executes the principals subcommand logic
|
||||
func runPrincipalsCmd(args []string) {
|
||||
opts := struct {
|
||||
PrincipalsFile string
|
||||
@@ -20,6 +82,7 @@ func runPrincipalsCmd(args []string) {
|
||||
PrincipalsFile: defaultPrincipalsPath,
|
||||
}
|
||||
|
||||
// Parse flags manually
|
||||
for i := 0; i < len(args); i++ {
|
||||
switch args[i] {
|
||||
case "--principals-file":
|
||||
@@ -46,12 +109,14 @@ func runPrincipalsCmd(args []string) {
|
||||
}
|
||||
}
|
||||
|
||||
// Validation
|
||||
if opts.Username == "" {
|
||||
fmt.Fprintf(os.Stderr, "Error: username is required\n")
|
||||
printPrincipalsHelp()
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Get principals
|
||||
list, err := authdaemon.GetPrincipals(opts.PrincipalsFile, opts.Username)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
|
||||
@@ -19,7 +19,7 @@ func (s *Server) ProcessConnection(req ConnectionRequest) {
|
||||
if err := ensureUser(req.Username, req.Metadata, s.cfg.GenerateRandomPassword); err != nil {
|
||||
logger.Warn("auth-daemon: ensure user: %v", err)
|
||||
}
|
||||
if cfg.PrincipalsFilePath != "" && req.NiceId != "" {
|
||||
if cfg.PrincipalsFilePath != "" {
|
||||
if err := writePrincipals(cfg.PrincipalsFilePath, req.Username, req.NiceId); err != nil {
|
||||
logger.Warn("auth-daemon: write principals: %v", err)
|
||||
}
|
||||
|
||||
@@ -14,9 +14,9 @@ func (s *Server) registerRoutes() {
|
||||
// ConnectionMetadata is the metadata object in POST /connection.
|
||||
type ConnectionMetadata struct {
|
||||
SudoMode string `json:"sudoMode"` // "none" | "full" | "commands"
|
||||
SudoCommands []string `json:"sudoCommands"` // used when sudoMode is "commands"
|
||||
SudoCommands []string `json:"sudoCommands"` // used when sudoMode is "commands"
|
||||
Homedir bool `json:"homedir"`
|
||||
Groups []string `json:"groups"` // system groups to add the user to
|
||||
Groups []string `json:"groups"` // system groups to add the user to
|
||||
}
|
||||
|
||||
// ConnectionRequest is the JSON body for POST /connection.
|
||||
|
||||
@@ -23,13 +23,13 @@ import (
|
||||
|
||||
type Config struct {
|
||||
// DisableHTTPS: when true, Run() does not start the HTTPS server (for embedded use inside Newt). Call ProcessConnection directly for connection events.
|
||||
DisableHTTPS bool
|
||||
Port int // Required when DisableHTTPS is false. Listen port for the HTTPS server. No default.
|
||||
PresharedKey string // Required when DisableHTTPS is false. HTTP auth (Authorization: Bearer <key> or X-Preshared-Key: <key>). No default.
|
||||
CACertPath string // Required. Where to write the CA cert (e.g. /etc/ssh/ca.pem). No default.
|
||||
Force bool // If true, overwrite existing CA cert (and other items) when content differs. Default false.
|
||||
PrincipalsFilePath string // Required. Path to the principals data file (JSON: username -> array of principals). No default.
|
||||
GenerateRandomPassword bool // If true, set a random password on users when they are provisioned (for SSH PermitEmptyPasswords no).
|
||||
DisableHTTPS bool
|
||||
Port int // Required when DisableHTTPS is false. Listen port for the HTTPS server. No default.
|
||||
PresharedKey string // Required when DisableHTTPS is false. HTTP auth (Authorization: Bearer <key> or X-Preshared-Key: <key>). No default.
|
||||
CACertPath string // Required. Where to write the CA cert (e.g. /etc/ssh/ca.pem). No default.
|
||||
Force bool // If true, overwrite existing CA cert (and other items) when content differs. Default false.
|
||||
PrincipalsFilePath string // Required. Path to the principals data file (JSON: username -> array of principals). No default.
|
||||
GenerateRandomPassword bool // If true, set a random password on users when they are provisioned (for SSH PermitEmptyPasswords no).
|
||||
}
|
||||
|
||||
type Server struct {
|
||||
@@ -136,7 +136,7 @@ func NewServer(cfg Config) (*Server, error) {
|
||||
// When DisableHTTPS is true, Run() blocks on ctx only and does not listen; use ProcessConnection for connection events.
|
||||
func (s *Server) Run(ctx context.Context) error {
|
||||
if s.cfg.DisableHTTPS {
|
||||
logger.Debug("auth-daemon running (HTTPS disabled)")
|
||||
logger.Info("auth-daemon running (HTTPS disabled)")
|
||||
<-ctx.Done()
|
||||
s.cleanupPrincipalsFile()
|
||||
return nil
|
||||
|
||||
@@ -1,144 +0,0 @@
|
||||
package browsergateway
|
||||
|
||||
import (
|
||||
"crypto/subtle"
|
||||
"errors"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/fosrl/newt/nativessh"
|
||||
)
|
||||
|
||||
// Forwarding buffer size. RDP graphics traffic is bursty and TLS records cap
|
||||
// at ~16 KiB, so 64 KiB lets a couple of records pile up per syscall/frame
|
||||
// without wasting memory per session.
|
||||
const forwardBufSize = 64 * 1024
|
||||
|
||||
// ListenPort is the port the browser gateway HTTP server listens on inside the
|
||||
// WireGuard netstack. This is a fixed value shared between newt and pangolin.
|
||||
// Targets do not overlap with this port because they start at 40000.
|
||||
const ListenPort = 39999
|
||||
|
||||
// Target represents an allowed proxy destination for the browser gateway.
|
||||
// Only connections whose (Type, Destination, DestinationPort, AuthToken) match a
|
||||
// registered Target will be forwarded; all others are rejected.
|
||||
type Target struct {
|
||||
ID int
|
||||
Type string // "rdp" | "ssh" | "vnc"
|
||||
Destination string
|
||||
DestinationPort int
|
||||
AuthToken string // per-target secret; must match the token supplied by the client
|
||||
}
|
||||
|
||||
// Config holds the configuration for a Gateway.
|
||||
type Config struct {
|
||||
// AuthToken is used only for NativeSSH mode (which has no external target
|
||||
// to match against). For all proxy targets (RDP/SSH/VNC), auth tokens are
|
||||
// stored per-Target and validated by isAllowed.
|
||||
AuthToken string
|
||||
// SSHCredentials are used by native SSH browser sessions for certificate
|
||||
// validation against the in-memory CA/principal store.
|
||||
SSHCredentials *nativessh.CredentialStore
|
||||
}
|
||||
|
||||
// Gateway is a browser-based RDP/SSH/VNC WebSocket proxy.
|
||||
// Create one with New and mount it via RegisterHandlers or the individual
|
||||
// HandleRDP / HandleSSH / HandleVNC http.HandlerFunc methods.
|
||||
type Gateway struct {
|
||||
authToken string
|
||||
sshCreds *nativessh.CredentialStore
|
||||
|
||||
mu sync.RWMutex
|
||||
targets map[int]Target // keyed by Target.ID
|
||||
|
||||
server *http.Server
|
||||
}
|
||||
|
||||
// New creates a new Gateway from the provided Config.
|
||||
func New(cfg Config) *Gateway {
|
||||
return &Gateway{
|
||||
authToken: cfg.AuthToken,
|
||||
sshCreds: cfg.SSHCredentials,
|
||||
targets: make(map[int]Target),
|
||||
}
|
||||
}
|
||||
|
||||
// SetTargets replaces the entire allowed-destination list atomically.
|
||||
func (g *Gateway) SetTargets(targets []Target) {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
g.targets = make(map[int]Target, len(targets))
|
||||
for _, t := range targets {
|
||||
g.targets[t.ID] = t
|
||||
}
|
||||
}
|
||||
|
||||
// AddTarget adds or updates a single allowed destination.
|
||||
func (g *Gateway) AddTarget(t Target) {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
g.targets[t.ID] = t
|
||||
}
|
||||
|
||||
// RemoveTarget removes an allowed destination by its ID.
|
||||
func (g *Gateway) RemoveTarget(id int) {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
delete(g.targets, id)
|
||||
}
|
||||
|
||||
// isAllowed reports whether a connection to (targetType, host, port) with the
|
||||
// given authToken is permitted. The token is compared against the per-target
|
||||
// AuthToken using constant-time comparison to prevent timing attacks.
|
||||
func (g *Gateway) isAllowed(targetType, host string, port int, authToken string) bool {
|
||||
g.mu.RLock()
|
||||
defer g.mu.RUnlock()
|
||||
for _, t := range g.targets {
|
||||
if t.Type == targetType && t.Destination == host && t.DestinationPort == port {
|
||||
return subtle.ConstantTimeCompare([]byte(authToken), []byte(t.AuthToken)) == 1
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// isTokenValid reports whether the given authToken matches any registered
|
||||
// target of the specified type. Used for native SSH mode where there is no
|
||||
// external destination to match against.
|
||||
func (g *Gateway) isTokenValid(targetType, authToken string) bool {
|
||||
g.mu.RLock()
|
||||
defer g.mu.RUnlock()
|
||||
for _, t := range g.targets {
|
||||
if t.Type == targetType {
|
||||
if subtle.ConstantTimeCompare([]byte(authToken), []byte(t.AuthToken)) == 1 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Start serves the browser gateway HTTP server on the provided listener.
|
||||
// It returns nil when the listener is closed (normal shutdown).
|
||||
func (g *Gateway) Start(ln net.Listener) error {
|
||||
mux := http.NewServeMux()
|
||||
g.RegisterHandlers(mux)
|
||||
g.server = &http.Server{Handler: mux}
|
||||
err := g.server.Serve(ln)
|
||||
if err == nil ||
|
||||
errors.Is(err, net.ErrClosed) ||
|
||||
errors.Is(err, http.ErrServerClosed) ||
|
||||
strings.Contains(err.Error(), "use of closed") ||
|
||||
strings.Contains(err.Error(), "invalid state") {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// RegisterHandlers registers the /rdp, /ssh, and /vnc routes on mux.
|
||||
func (g *Gateway) RegisterHandlers(mux *http.ServeMux) {
|
||||
mux.HandleFunc("/gateway/rdp", g.HandleRDP)
|
||||
mux.HandleFunc("/gateway/ssh", g.HandleSSH)
|
||||
mux.HandleFunc("/gateway/vnc", g.handleVNC)
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
package browsergateway
|
||||
|
||||
import (
|
||||
"encoding/asn1"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// RDCleanPath PDU version (BASE_VERSION + 1 = 3389 + 1).
|
||||
const rdCleanPathVersion = int64(3390)
|
||||
|
||||
// rdCleanPathPdu is a Go translation of the ASN.1 SEQUENCE defined in the
|
||||
// ironrdp-rdcleanpath crate. All optional fields use EXPLICIT context-specific
|
||||
// tagging, matching the Rust `der::Sequence` derivation with
|
||||
// `tag_mode = "EXPLICIT"`.
|
||||
//
|
||||
// We only need a subset of fields for the basic proxy flow, but the struct
|
||||
// declares every tag we may encounter so that decoding does not fail on an
|
||||
// unexpected element.
|
||||
type rdCleanPathPdu struct {
|
||||
Version int64 `asn1:"explicit,tag:0"`
|
||||
Destination string `asn1:"explicit,tag:2,optional,utf8"`
|
||||
ProxyAuth string `asn1:"explicit,tag:3,optional,utf8"`
|
||||
ServerAuth string `asn1:"explicit,tag:4,optional,utf8"`
|
||||
PreconnectionBlob string `asn1:"explicit,tag:5,optional,utf8"`
|
||||
X224 []byte `asn1:"explicit,tag:6,optional"`
|
||||
ServerCertChain [][]byte `asn1:"explicit,tag:7,optional"`
|
||||
ServerAddr string `asn1:"explicit,tag:9,optional,utf8"`
|
||||
}
|
||||
|
||||
// decodeRDCleanPathRequest parses a client-to-proxy RDCleanPath PDU.
|
||||
func decodeRDCleanPathRequest(buf []byte) (*rdCleanPathPdu, error) {
|
||||
var pdu rdCleanPathPdu
|
||||
rest, err := asn1.Unmarshal(buf, &pdu)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("asn1 unmarshal: %w", err)
|
||||
}
|
||||
if len(rest) != 0 {
|
||||
return nil, fmt.Errorf("trailing data after RDCleanPath PDU: %d bytes", len(rest))
|
||||
}
|
||||
if pdu.Version != rdCleanPathVersion {
|
||||
return nil, fmt.Errorf("unexpected RDCleanPath version: %d", pdu.Version)
|
||||
}
|
||||
return &pdu, nil
|
||||
}
|
||||
|
||||
// encodeRDCleanPathResponse builds a proxy-to-client RDCleanPath response PDU
|
||||
// containing the server address, X.224 connection confirm and server TLS chain.
|
||||
func encodeRDCleanPathResponse(serverAddr string, x224Rsp []byte, certChain [][]byte) ([]byte, error) {
|
||||
pdu := rdCleanPathPdu{
|
||||
Version: rdCleanPathVersion,
|
||||
X224: x224Rsp,
|
||||
ServerCertChain: certChain,
|
||||
ServerAddr: serverAddr,
|
||||
}
|
||||
return asn1.Marshal(pdu)
|
||||
}
|
||||
|
||||
// detectRDCleanPathLength returns the total DER length of an RDCleanPath PDU
|
||||
// if enough bytes are available, otherwise -1.
|
||||
//
|
||||
// The PDU is a DER SEQUENCE, which begins with the universal SEQUENCE tag
|
||||
// (0x30) followed by a length octet/octets. We parse just enough to know the
|
||||
// total length so we can buffer accordingly.
|
||||
func detectRDCleanPathLength(buf []byte) int {
|
||||
if len(buf) < 2 {
|
||||
return -1
|
||||
}
|
||||
if buf[0] != 0x30 {
|
||||
// Not a SEQUENCE: cannot be RDCleanPath.
|
||||
return -2
|
||||
}
|
||||
l := buf[1]
|
||||
if l < 0x80 {
|
||||
return 2 + int(l)
|
||||
}
|
||||
n := int(l & 0x7f)
|
||||
if n == 0 || n > 4 {
|
||||
return -2
|
||||
}
|
||||
if len(buf) < 2+n {
|
||||
return -1
|
||||
}
|
||||
total := 0
|
||||
for i := 0; i < n; i++ {
|
||||
total = (total << 8) | int(buf[2+i])
|
||||
}
|
||||
return 2 + n + total
|
||||
}
|
||||
@@ -1,271 +0,0 @@
|
||||
package browsergateway
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/coder/websocket"
|
||||
"github.com/fosrl/newt/logger"
|
||||
)
|
||||
|
||||
// HandleRDP is an http.HandlerFunc for RDP-over-WebSocket connections.
|
||||
func (g *Gateway) HandleRDP(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
ws, err := websocket.Accept(w, r, &websocket.AcceptOptions{
|
||||
InsecureSkipVerify: true, // any-origin: minimal dev proxy with no auth
|
||||
Subprotocols: []string{"binary"},
|
||||
})
|
||||
if err != nil {
|
||||
logger.Debug("websocket upgrade failed: %v", err)
|
||||
return
|
||||
}
|
||||
// Disable per-message read size cap (default is 32 KiB which would break
|
||||
// large RDP graphics messages).
|
||||
ws.SetReadLimit(-1)
|
||||
defer ws.CloseNow() //nolint:errcheck
|
||||
|
||||
if err := g.serveSession(ctx, ws); err != nil {
|
||||
logger.Debug("session error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (g *Gateway) serveSession(ctx context.Context, ws *websocket.Conn) error {
|
||||
// Expose the WebSocket as a streaming net.Conn. Binary messages are
|
||||
// concatenated into a byte stream and writes become single binary frames.
|
||||
// This is a thin wrapper with no per-message goroutine, unlike Gorilla.
|
||||
stream := websocket.NetConn(ctx, ws, websocket.MessageBinary)
|
||||
defer stream.Close() //nolint:errcheck
|
||||
|
||||
// -- Read the initial RDCleanPath request from the client --
|
||||
pdu, err := readCleanPath(stream)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read RDCleanPath: %w", err)
|
||||
}
|
||||
|
||||
if pdu.Destination == "" {
|
||||
return errors.New("RDCleanPath missing destination")
|
||||
}
|
||||
if len(pdu.X224) == 0 {
|
||||
return errors.New("RDCleanPath missing X224 connection PDU")
|
||||
}
|
||||
|
||||
target := pdu.Destination
|
||||
// Default port for RDP if not specified.
|
||||
if _, _, splitErr := net.SplitHostPort(target); splitErr != nil {
|
||||
target = net.JoinHostPort(target, "3389")
|
||||
}
|
||||
|
||||
// Validate destination against the registered target allowlist,
|
||||
// including per-target auth token.
|
||||
rdpHost, rdpPortStr, _ := net.SplitHostPort(target)
|
||||
rdpPort, _ := strconv.Atoi(rdpPortStr)
|
||||
if !g.isAllowed("rdp", rdpHost, rdpPort, pdu.ProxyAuth) {
|
||||
return fmt.Errorf("RDP destination %s is not in the allowed target list or auth token mismatch", target)
|
||||
}
|
||||
|
||||
logger.Debug("Connecting to RDP server %s", target)
|
||||
|
||||
// -- Open TCP connection to the destination RDP server --
|
||||
serverTCP, err := net.DialTimeout("tcp", target, 15*time.Second)
|
||||
if err != nil {
|
||||
return fmt.Errorf("dial %s: %w", target, err)
|
||||
}
|
||||
defer serverTCP.Close()
|
||||
if tcp, ok := serverTCP.(*net.TCPConn); ok {
|
||||
// NoDelay is Go's default; set explicitly. RDP wants low latency for
|
||||
// input echo, and the bulk path is naturally chunked by TLS records.
|
||||
_ = tcp.SetNoDelay(true)
|
||||
_ = tcp.SetKeepAlive(true)
|
||||
_ = tcp.SetKeepAlivePeriod(30 * time.Second)
|
||||
_ = tcp.SetReadBuffer(forwardBufSize)
|
||||
_ = tcp.SetWriteBuffer(forwardBufSize)
|
||||
}
|
||||
serverAddr := serverTCP.RemoteAddr().String()
|
||||
|
||||
// Forward the optional pre-connection blob, then the X.224 connection request.
|
||||
if pdu.PreconnectionBlob != "" {
|
||||
if _, err := serverTCP.Write([]byte(pdu.PreconnectionBlob)); err != nil {
|
||||
return fmt.Errorf("send PCB: %w", err)
|
||||
}
|
||||
}
|
||||
if _, err := serverTCP.Write(pdu.X224); err != nil {
|
||||
return fmt.Errorf("send X224: %w", err)
|
||||
}
|
||||
|
||||
// -- Read the X.224 connection confirm from the server --
|
||||
x224Rsp, err := readX224(serverTCP)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read X224 response: %w", err)
|
||||
}
|
||||
logX224Negotiation(x224Rsp)
|
||||
|
||||
// -- Upgrade the server connection to TLS (skip verification) --
|
||||
//
|
||||
// Windows RDP hosts are picky: only set SNI when the target is a hostname
|
||||
// (Go would skip SNI for IP literals anyway, but be explicit), and accept
|
||||
// the full range of TLS versions / ciphers since some servers only
|
||||
// negotiate TLS 1.0 or legacy suites.
|
||||
host, _, _ := net.SplitHostPort(target)
|
||||
tlsCfg := &tls.Config{
|
||||
InsecureSkipVerify: true, //nolint:gosec // proxy intentionally skips verification
|
||||
MinVersion: tls.VersionTLS10,
|
||||
// Cap at TLS 1.2: Windows RDP servers commonly send a TLS "internal_error"
|
||||
// alert when CredSSP/NLA is layered on top of a TLS 1.3 session.
|
||||
MaxVersion: tls.VersionTLS12,
|
||||
}
|
||||
if net.ParseIP(host) == nil {
|
||||
tlsCfg.ServerName = host
|
||||
}
|
||||
tlsConn := tls.Client(serverTCP, tlsCfg)
|
||||
if err := tlsConn.Handshake(); err != nil {
|
||||
return fmt.Errorf("TLS handshake with server: %w", err)
|
||||
}
|
||||
logger.Debug("Server TLS handshake OK (version=0x%04x cipher=0x%04x)",
|
||||
tlsConn.ConnectionState().Version, tlsConn.ConnectionState().CipherSuite)
|
||||
|
||||
// Collect the raw DER server certificate chain to return to the client.
|
||||
state := tlsConn.ConnectionState()
|
||||
if len(state.PeerCertificates) == 0 {
|
||||
return errors.New("server did not present any certificates")
|
||||
}
|
||||
chain := make([][]byte, 0, len(state.PeerCertificates))
|
||||
for _, c := range state.PeerCertificates {
|
||||
chain = append(chain, c.Raw)
|
||||
}
|
||||
|
||||
// -- Send the RDCleanPath response back to the client --
|
||||
rsp, err := encodeRDCleanPathResponse(serverAddr, x224Rsp, chain)
|
||||
if err != nil {
|
||||
return fmt.Errorf("encode RDCleanPath response: %w", err)
|
||||
}
|
||||
if _, err := stream.Write(rsp); err != nil {
|
||||
return fmt.Errorf("write RDCleanPath response: %w", err)
|
||||
}
|
||||
|
||||
logger.Debug("RDCleanPath handshake complete, forwarding traffic to %s", serverAddr)
|
||||
|
||||
// -- Two-way blind forwarding of the (now TLS-encrypted) RDP stream --
|
||||
return forward(stream, tlsConn)
|
||||
}
|
||||
|
||||
// readCleanPath buffers bytes from the stream until a full RDCleanPath PDU has
|
||||
// been received, then decodes it.
|
||||
func readCleanPath(r io.Reader) (*rdCleanPathPdu, error) {
|
||||
buf := make([]byte, 0, 1024)
|
||||
tmp := make([]byte, 1024)
|
||||
for {
|
||||
total := detectRDCleanPathLength(buf)
|
||||
switch {
|
||||
case total == -2:
|
||||
return nil, errors.New("invalid RDCleanPath PDU")
|
||||
case total > 0 && len(buf) >= total:
|
||||
return decodeRDCleanPathRequest(buf[:total])
|
||||
}
|
||||
n, err := r.Read(tmp)
|
||||
if n > 0 {
|
||||
buf = append(buf, tmp[:n]...)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// readX224 reads exactly one TPKT-framed X.224 PDU from the server.
|
||||
//
|
||||
// The TPKT header is 4 bytes: version (0x03), reserved (0x00), and a u16
|
||||
// big-endian total length that includes the header itself.
|
||||
func readX224(r io.Reader) ([]byte, error) {
|
||||
hdr := make([]byte, 4)
|
||||
if _, err := io.ReadFull(r, hdr); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if hdr[0] != 0x03 {
|
||||
return nil, fmt.Errorf("unexpected TPKT version 0x%02x", hdr[0])
|
||||
}
|
||||
total := int(binary.BigEndian.Uint16(hdr[2:4]))
|
||||
if total < 4 || total > 4096 {
|
||||
return nil, fmt.Errorf("unreasonable TPKT length %d", total)
|
||||
}
|
||||
out := make([]byte, total)
|
||||
copy(out, hdr)
|
||||
if _, err := io.ReadFull(r, out[4:]); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// logX224Negotiation prints which RDP security protocol the server selected
|
||||
// (or the failure code), to help diagnose handshake issues such as the server
|
||||
// requiring NLA/CredSSP.
|
||||
//
|
||||
// X.224 Connection Confirm layout (RFC 1006 / [MS-RDPBCGR]):
|
||||
//
|
||||
// bytes 0..3 TPKT header (03 00 LL LL)
|
||||
// byte 4 X.224 length indicator
|
||||
// byte 5 X.224 code (0xD0 = CC)
|
||||
// bytes 6..10 DST-REF, SRC-REF, class
|
||||
// byte 11 optional RDP Negotiation type (0x02 = response, 0x03 = failure)
|
||||
// byte 12 flags
|
||||
// bytes 13..14 length (little-endian, =8)
|
||||
// bytes 15..18 selected protocol / failure code (u32 little-endian)
|
||||
func logX224Negotiation(pdu []byte) {
|
||||
if len(pdu) < 19 {
|
||||
logger.Debug("X.224 response too short (%d bytes) to contain RDP negotiation", len(pdu))
|
||||
return
|
||||
}
|
||||
switch pdu[11] {
|
||||
case 0x02:
|
||||
proto := uint32(pdu[15]) | uint32(pdu[16])<<8 | uint32(pdu[17])<<16 | uint32(pdu[18])<<24
|
||||
name := "unknown"
|
||||
switch proto {
|
||||
case 0:
|
||||
name = "RDP (standard)"
|
||||
case 1:
|
||||
name = "SSL/TLS"
|
||||
case 2:
|
||||
name = "HYBRID (CredSSP/NLA)"
|
||||
case 8:
|
||||
name = "HYBRID_EX"
|
||||
}
|
||||
logger.Debug("Server selected RDP protocol 0x%x (%s)", proto, name)
|
||||
case 0x03:
|
||||
code := uint32(pdu[15]) | uint32(pdu[16])<<8 | uint32(pdu[17])<<16 | uint32(pdu[18])<<24
|
||||
logger.Debug("Server returned RDP negotiation failure code 0x%x", code)
|
||||
default:
|
||||
logger.Debug("X.224 response has no RDP negotiation block (type=0x%02x)", pdu[11])
|
||||
}
|
||||
}
|
||||
|
||||
// forward shuttles bytes between the two streams until either side closes.
|
||||
func forward(a, b io.ReadWriteCloser) error {
|
||||
errc := make(chan error, 2)
|
||||
go func() {
|
||||
buf := make([]byte, forwardBufSize)
|
||||
_, err := io.CopyBuffer(a, b, buf)
|
||||
_ = a.Close()
|
||||
_ = b.Close()
|
||||
errc <- err
|
||||
}()
|
||||
go func() {
|
||||
buf := make([]byte, forwardBufSize)
|
||||
_, err := io.CopyBuffer(b, a, buf)
|
||||
_ = a.Close()
|
||||
_ = b.Close()
|
||||
errc <- err
|
||||
}()
|
||||
// Wait for one side to finish, then return.
|
||||
err := <-errc
|
||||
if errors.Is(err, io.EOF) || err == nil {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
@@ -1,282 +0,0 @@
|
||||
package browsergateway
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/coder/websocket"
|
||||
"github.com/fosrl/newt/logger"
|
||||
"golang.org/x/crypto/ssh"
|
||||
)
|
||||
|
||||
// sshClientMsg is a JSON message sent from the browser to the proxy.
|
||||
type sshClientMsg struct {
|
||||
// type: "auth" | "data" | "resize"
|
||||
Type string `json:"type"`
|
||||
Password string `json:"password,omitempty"` // used when type="auth"
|
||||
PrivateKey string `json:"privateKey,omitempty"` // used when type="auth"
|
||||
Certificate string `json:"certificate,omitempty"` // used when type="auth"
|
||||
Data string `json:"data,omitempty"` // used when type="data"
|
||||
Cols uint32 `json:"cols,omitempty"` // used when type="resize"
|
||||
Rows uint32 `json:"rows,omitempty"` // used when type="resize"
|
||||
}
|
||||
|
||||
// sshServerMsg is a JSON message sent from the proxy back to the browser.
|
||||
type sshServerMsg struct {
|
||||
// type: "data" | "error"
|
||||
Type string `json:"type"`
|
||||
Data string `json:"data,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// HandleSSH is an http.HandlerFunc for SSH-over-WebSocket connections.
|
||||
func (g *Gateway) HandleSSH(w http.ResponseWriter, r *http.Request) {
|
||||
logger.Debug("SSH connection request from %s", r.RemoteAddr)
|
||||
ctx := r.Context()
|
||||
|
||||
token := r.URL.Query().Get("authToken")
|
||||
|
||||
// "mode=native" (default) connects to the local SSH daemon on this host.
|
||||
// "mode=proxy" connects to an arbitrary host+port supplied in query params.
|
||||
nativeSSH := r.URL.Query().Get("mode") != "proxy"
|
||||
|
||||
// In proxy mode we also need host + username from query params.
|
||||
var target, username string
|
||||
if !nativeSSH {
|
||||
host := r.URL.Query().Get("host")
|
||||
port := r.URL.Query().Get("port")
|
||||
username = r.URL.Query().Get("username")
|
||||
if host == "" || username == "" {
|
||||
http.Error(w, "missing host or username", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if port == "" {
|
||||
port = "22"
|
||||
}
|
||||
sshPort, _ := strconv.Atoi(port)
|
||||
if !g.isAllowed("ssh", host, sshPort, token) {
|
||||
http.Error(w, "destination not allowed or auth token mismatch", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
target = net.JoinHostPort(host, port)
|
||||
} else {
|
||||
// Native SSH mode: validate the token against any registered ssh target.
|
||||
if !g.isTokenValid("ssh", token) {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
username = r.URL.Query().Get("username")
|
||||
if username == "" {
|
||||
http.Error(w, "missing username", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
ws, err := websocket.Accept(w, r, &websocket.AcceptOptions{
|
||||
InsecureSkipVerify: true,
|
||||
Subprotocols: []string{"ssh"},
|
||||
})
|
||||
if err != nil {
|
||||
logger.Debug("SSH websocket upgrade failed: %v", err)
|
||||
return
|
||||
}
|
||||
ws.SetReadLimit(-1)
|
||||
defer ws.CloseNow() //nolint:errcheck
|
||||
|
||||
if nativeSSH {
|
||||
if err := serveNativeSSHSession(ctx, ws, username, g.sshCreds); err != nil {
|
||||
logger.Debug("SSH native session error: %v", err)
|
||||
}
|
||||
} else {
|
||||
if err := serveSSHSession(ctx, ws, target, username, g.authToken); err != nil {
|
||||
logger.Debug("SSH session error: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func serveSSHSession(ctx context.Context, ws *websocket.Conn, target, username, _ string) error {
|
||||
// -- Wait for the auth message from the client to get the password --
|
||||
_, authBytes, err := ws.Read(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read auth message: %w", err)
|
||||
}
|
||||
var authMsg sshClientMsg
|
||||
if err := json.Unmarshal(authBytes, &authMsg); err != nil || authMsg.Type != "auth" {
|
||||
return fmt.Errorf("expected auth message, got: %s", authBytes)
|
||||
}
|
||||
password := authMsg.Password
|
||||
privateKey := authMsg.PrivateKey
|
||||
certificate := authMsg.Certificate
|
||||
|
||||
// Build the list of auth methods. Certificate+private key takes priority
|
||||
// when both are provided, then private key, then password.
|
||||
var authMethods []ssh.AuthMethod
|
||||
if privateKey != "" {
|
||||
signer, err := ssh.ParsePrivateKey([]byte(privateKey))
|
||||
if err != nil {
|
||||
sendSSHError(ctx, ws, fmt.Sprintf("Failed to parse private key: %v", err))
|
||||
return fmt.Errorf("parse private key: %w", err)
|
||||
}
|
||||
|
||||
if certificate != "" {
|
||||
certPubKey, _, _, _, err := ssh.ParseAuthorizedKey([]byte(certificate))
|
||||
if err != nil {
|
||||
sendSSHError(ctx, ws, fmt.Sprintf("Failed to parse certificate: %v", err))
|
||||
return fmt.Errorf("parse certificate: %w", err)
|
||||
}
|
||||
|
||||
cert, ok := certPubKey.(*ssh.Certificate)
|
||||
if !ok {
|
||||
sendSSHError(ctx, ws, "Provided certificate is not a valid SSH certificate")
|
||||
return fmt.Errorf("provided certificate is not ssh certificate")
|
||||
}
|
||||
|
||||
certSigner, err := ssh.NewCertSigner(cert, signer)
|
||||
if err != nil {
|
||||
sendSSHError(ctx, ws, fmt.Sprintf("Failed to combine private key and certificate: %v", err))
|
||||
return fmt.Errorf("new cert signer: %w", err)
|
||||
}
|
||||
authMethods = append(authMethods, ssh.PublicKeys(certSigner))
|
||||
} else {
|
||||
authMethods = append(authMethods, ssh.PublicKeys(signer))
|
||||
}
|
||||
}
|
||||
if password != "" {
|
||||
authMethods = append(authMethods, ssh.Password(password))
|
||||
}
|
||||
if len(authMethods) == 0 {
|
||||
sendSSHError(ctx, ws, "No authentication credentials provided")
|
||||
return fmt.Errorf("no auth credentials")
|
||||
}
|
||||
logger.Debug("SSH: connecting to %s as %s", target, username)
|
||||
sshCfg := &ssh.ClientConfig{
|
||||
User: username,
|
||||
Auth: authMethods,
|
||||
// HostKeyCallback is intentionally InsecureIgnoreHostKey for this dev
|
||||
// proxy. In production, verify against a known-hosts store.
|
||||
HostKeyCallback: ssh.InsecureIgnoreHostKey(), //nolint:gosec
|
||||
Timeout: 15 * time.Second,
|
||||
}
|
||||
|
||||
sshClient, err := ssh.Dial("tcp", target, sshCfg)
|
||||
if err != nil {
|
||||
sendSSHError(ctx, ws, fmt.Sprintf("SSH dial failed: %v", err))
|
||||
return fmt.Errorf("ssh dial %s: %w", target, err)
|
||||
}
|
||||
defer sshClient.Close()
|
||||
|
||||
// -- Open an interactive session --
|
||||
sess, err := sshClient.NewSession()
|
||||
if err != nil {
|
||||
sendSSHError(ctx, ws, fmt.Sprintf("Failed to open SSH session: %v", err))
|
||||
return fmt.Errorf("ssh new session: %w", err)
|
||||
}
|
||||
defer sess.Close()
|
||||
|
||||
// Request a PTY.
|
||||
if err := sess.RequestPty("xterm-256color", 24, 80, ssh.TerminalModes{
|
||||
ssh.ECHO: 1,
|
||||
ssh.TTY_OP_ISPEED: 38400,
|
||||
ssh.TTY_OP_OSPEED: 38400,
|
||||
}); err != nil {
|
||||
sendSSHError(ctx, ws, fmt.Sprintf("Failed to request PTY: %v", err))
|
||||
return fmt.Errorf("ssh request pty: %w", err)
|
||||
}
|
||||
|
||||
stdinPipe, err := sess.StdinPipe()
|
||||
if err != nil {
|
||||
return fmt.Errorf("ssh stdin pipe: %w", err)
|
||||
}
|
||||
stdoutPipe, err := sess.StdoutPipe()
|
||||
if err != nil {
|
||||
return fmt.Errorf("ssh stdout pipe: %w", err)
|
||||
}
|
||||
stderrPipe, err := sess.StderrPipe()
|
||||
if err != nil {
|
||||
return fmt.Errorf("ssh stderr pipe: %w", err)
|
||||
}
|
||||
|
||||
if err := sess.Shell(); err != nil {
|
||||
sendSSHError(ctx, ws, fmt.Sprintf("Failed to start shell: %v", err))
|
||||
return fmt.Errorf("ssh shell: %w", err)
|
||||
}
|
||||
|
||||
logger.Debug("SSH: session established with %s", target)
|
||||
|
||||
// -- Pump SSH stdout/stderr → WebSocket --
|
||||
sessCtx, cancelSess := context.WithCancel(ctx)
|
||||
defer cancelSess()
|
||||
|
||||
go func() {
|
||||
buf := make([]byte, 4096)
|
||||
for {
|
||||
n, readErr := stdoutPipe.Read(buf)
|
||||
if n > 0 {
|
||||
msg := sshServerMsg{Type: "data", Data: string(buf[:n])}
|
||||
b, _ := json.Marshal(msg)
|
||||
if writeErr := ws.Write(sessCtx, websocket.MessageText, b); writeErr != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
if readErr != nil {
|
||||
cancelSess()
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
go func() {
|
||||
buf := make([]byte, 4096)
|
||||
for {
|
||||
n, readErr := stderrPipe.Read(buf)
|
||||
if n > 0 {
|
||||
msg := sshServerMsg{Type: "data", Data: string(buf[:n])}
|
||||
b, _ := json.Marshal(msg)
|
||||
if writeErr := ws.Write(sessCtx, websocket.MessageText, b); writeErr != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
if readErr != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// -- Pump WebSocket input → SSH stdin / resize --
|
||||
for {
|
||||
_, msgBytes, readErr := ws.Read(sessCtx)
|
||||
if readErr != nil {
|
||||
break
|
||||
}
|
||||
|
||||
var msg sshClientMsg
|
||||
if err := json.Unmarshal(msgBytes, &msg); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
switch msg.Type {
|
||||
case "data":
|
||||
if _, err := stdinPipe.Write([]byte(msg.Data)); err != nil {
|
||||
return fmt.Errorf("write ssh stdin: %w", err)
|
||||
}
|
||||
case "resize":
|
||||
if msg.Cols > 0 && msg.Rows > 0 {
|
||||
_ = sess.WindowChange(int(msg.Rows), int(msg.Cols))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// sendSSHError sends an error message to the browser and logs it.
|
||||
func sendSSHError(ctx context.Context, ws *websocket.Conn, msg string) {
|
||||
logger.Debug("SSH error: %s", msg)
|
||||
b, _ := json.Marshal(sshServerMsg{Type: "error", Error: msg})
|
||||
_ = ws.Write(ctx, websocket.MessageText, b)
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
//go:build !windows
|
||||
|
||||
package browsergateway
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/coder/websocket"
|
||||
"github.com/fosrl/newt/logger"
|
||||
"github.com/fosrl/newt/nativessh"
|
||||
)
|
||||
|
||||
// serveNativeSSHSession handles a WebSocket SSH session by authenticating the
|
||||
// user against the host OS (authorized_keys then PAM password) and then
|
||||
// spawning a PTY+shell running as that user.
|
||||
//
|
||||
// The auth frame from the browser must be a JSON sshClientMsg with type="auth"
|
||||
// carrying the same password/privateKey fields used by the proxy SSH path.
|
||||
// The target username is passed in from the HTTP layer (query param).
|
||||
func serveNativeSSHSession(ctx context.Context, ws *websocket.Conn, username string, creds *nativessh.CredentialStore) error {
|
||||
// Read the auth frame.
|
||||
_, authBytes, err := ws.Read(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read auth message: %w", err)
|
||||
}
|
||||
var authMsg sshClientMsg
|
||||
if err := json.Unmarshal(authBytes, &authMsg); err != nil || authMsg.Type != "auth" {
|
||||
return fmt.Errorf("expected auth message, got: %s", authBytes)
|
||||
}
|
||||
|
||||
// Authenticate using host authorized_keys or PAM password.
|
||||
if err := nativessh.AuthenticateWithCertificate(creds, username, authMsg.Password, authMsg.PrivateKey, authMsg.Certificate); err != nil {
|
||||
sendSSHError(ctx, ws, "Authentication failed")
|
||||
return fmt.Errorf("auth for user %q: %w", username, err)
|
||||
}
|
||||
|
||||
logger.Debug("SSH native: spawning shell as user %q", username)
|
||||
|
||||
sess, err := nativessh.NewPTYSessionAs(username)
|
||||
if err != nil {
|
||||
sendSSHError(ctx, ws, fmt.Sprintf("Failed to spawn shell: %v", err))
|
||||
return fmt.Errorf("pty session as %q: %w", username, err)
|
||||
}
|
||||
defer sess.Close()
|
||||
|
||||
// Cancel context to unblock the WebSocket read loop when the shell exits.
|
||||
sessCtx, cancelSess := context.WithCancel(ctx)
|
||||
defer cancelSess()
|
||||
|
||||
// Pump PTY output → WebSocket.
|
||||
go func() {
|
||||
defer cancelSess()
|
||||
buf := make([]byte, 4096)
|
||||
for {
|
||||
n, readErr := sess.Read(buf)
|
||||
if n > 0 {
|
||||
msg := sshServerMsg{Type: "data", Data: string(buf[:n])}
|
||||
b, _ := json.Marshal(msg)
|
||||
if writeErr := ws.Write(sessCtx, websocket.MessageText, b); writeErr != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
if readErr != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// Pump WebSocket input → PTY stdin / resize.
|
||||
for {
|
||||
_, msgBytes, readErr := ws.Read(sessCtx)
|
||||
if readErr != nil {
|
||||
break
|
||||
}
|
||||
var msg sshClientMsg
|
||||
if err := json.Unmarshal(msgBytes, &msg); err != nil {
|
||||
continue
|
||||
}
|
||||
switch msg.Type {
|
||||
case "data":
|
||||
if _, writeErr := sess.Write([]byte(msg.Data)); writeErr != nil {
|
||||
return fmt.Errorf("write pty: %w", writeErr)
|
||||
}
|
||||
case "resize":
|
||||
if msg.Cols > 0 && msg.Rows > 0 {
|
||||
_ = sess.Resize(uint16(msg.Cols), uint16(msg.Rows))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
//go:build windows
|
||||
|
||||
package browsergateway
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/coder/websocket"
|
||||
"github.com/fosrl/newt/nativessh"
|
||||
)
|
||||
|
||||
// serveNativeSSHSession is not supported on Windows.
|
||||
func serveNativeSSHSession(_ context.Context, _ *websocket.Conn, _ string, _ *nativessh.CredentialStore) error {
|
||||
return errors.New("native SSH is not supported on Windows")
|
||||
}
|
||||
@@ -1,125 +0,0 @@
|
||||
package browsergateway
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/coder/websocket"
|
||||
"github.com/fosrl/newt/logger"
|
||||
)
|
||||
|
||||
const (
|
||||
vncDialTimeout = 10 * time.Second
|
||||
vncKeepAlive = 30 * time.Second
|
||||
vncForwardBufSize = 32 * 1024
|
||||
)
|
||||
|
||||
// handleVNC proxies a noVNC WebSocket connection to a raw TCP VNC backend.
|
||||
// It follows the same auth-token-in-query-param pattern as handleSSH.
|
||||
//
|
||||
// Query parameters:
|
||||
//
|
||||
// authToken – shared secret matching the -auth-token flag
|
||||
// host – VNC backend hostname or IP
|
||||
// port – VNC backend port (default: 5900)
|
||||
func (g *Gateway) handleVNC(w http.ResponseWriter, r *http.Request) {
|
||||
host := r.URL.Query().Get("host")
|
||||
port := r.URL.Query().Get("port")
|
||||
if host == "" {
|
||||
http.Error(w, "missing host", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if port == "" {
|
||||
port = "5900"
|
||||
}
|
||||
vncPort, _ := strconv.Atoi(port)
|
||||
authToken := r.URL.Query().Get("authToken")
|
||||
if !g.isAllowed("vnc", host, vncPort, authToken) {
|
||||
http.Error(w, "destination not allowed or auth token mismatch", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
target := net.JoinHostPort(host, port)
|
||||
|
||||
// Optional HTTP probe used by the web UI to surface backend reachability
|
||||
// failures before attempting a WebSocket session.
|
||||
if r.URL.Query().Get("checkOnly") == "1" {
|
||||
if err := dialVNCBackend(r.Context(), target); err != nil {
|
||||
logger.Debug("vnc: preflight failed (%s): %v", target, err)
|
||||
http.Error(w, fmt.Sprintf("failed to connect to VNC backend: %v", err), http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
|
||||
// Accept the WebSocket. noVNC negotiates the "binary" subprotocol;
|
||||
// fall back gracefully when the client sends "base64" as well.
|
||||
ws, err := websocket.Accept(w, r, &websocket.AcceptOptions{
|
||||
InsecureSkipVerify: true,
|
||||
Subprotocols: []string{"binary", "base64"},
|
||||
})
|
||||
if err != nil {
|
||||
logger.Debug("vnc: websocket upgrade failed: %v", err)
|
||||
return
|
||||
}
|
||||
ws.SetReadLimit(-1)
|
||||
defer ws.CloseNow() //nolint:errcheck
|
||||
|
||||
ctx := r.Context()
|
||||
if err := serveVNC(ctx, ws, target); err != nil {
|
||||
logger.Debug("vnc: session error (%s): %v", target, err)
|
||||
}
|
||||
}
|
||||
|
||||
func dialVNCBackend(ctx context.Context, target string) error {
|
||||
dialer := &net.Dialer{
|
||||
Timeout: vncDialTimeout,
|
||||
KeepAlive: vncKeepAlive,
|
||||
}
|
||||
conn, err := dialer.DialContext(ctx, "tcp", target)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer conn.Close() //nolint:errcheck
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func serveVNC(ctx context.Context, ws *websocket.Conn, target string) error {
|
||||
// Dial the VNC backend TCP server.
|
||||
dialer := &net.Dialer{
|
||||
Timeout: vncDialTimeout,
|
||||
KeepAlive: vncKeepAlive,
|
||||
}
|
||||
conn, err := dialer.DialContext(ctx, "tcp", target)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer conn.Close() //nolint:errcheck
|
||||
|
||||
// Expose the WebSocket as a plain net.Conn byte stream (binary frames).
|
||||
stream := websocket.NetConn(ctx, ws, websocket.MessageBinary)
|
||||
defer stream.Close() //nolint:errcheck
|
||||
|
||||
// Proxy bidirectionally: VNC backend <-> browser.
|
||||
errc := make(chan error, 2)
|
||||
go func() {
|
||||
buf := make([]byte, vncForwardBufSize)
|
||||
_, err := io.CopyBuffer(conn, stream, buf)
|
||||
errc <- err
|
||||
}()
|
||||
go func() {
|
||||
buf := make([]byte, vncForwardBufSize)
|
||||
_, err := io.CopyBuffer(stream, conn, buf)
|
||||
errc <- err
|
||||
}()
|
||||
|
||||
// Return when either direction closes.
|
||||
err = <-errc
|
||||
return err
|
||||
}
|
||||
104
clients.go
Normal file
104
clients.go
Normal file
@@ -0,0 +1,104 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/fosrl/newt/clients"
|
||||
wgnetstack "github.com/fosrl/newt/clients"
|
||||
"github.com/fosrl/newt/clients/permissions"
|
||||
"github.com/fosrl/newt/logger"
|
||||
"github.com/fosrl/newt/websocket"
|
||||
"golang.zx2c4.com/wireguard/tun/netstack"
|
||||
)
|
||||
|
||||
var wgService *clients.WireGuardService
|
||||
var ready bool
|
||||
|
||||
func setupClients(client *websocket.Client) {
|
||||
var host = endpoint
|
||||
if strings.HasPrefix(host, "http://") {
|
||||
host = strings.TrimPrefix(host, "http://")
|
||||
} else if strings.HasPrefix(host, "https://") {
|
||||
host = strings.TrimPrefix(host, "https://")
|
||||
}
|
||||
|
||||
host = strings.TrimSuffix(host, "/")
|
||||
|
||||
logger.Debug("Setting up clients with netstack2...")
|
||||
|
||||
// if useNativeInterface is true make sure we have permission to use native interface
|
||||
if useNativeInterface {
|
||||
logger.Debug("Checking permissions for native interface")
|
||||
err := permissions.CheckNativeInterfacePermissions()
|
||||
if err != nil {
|
||||
logger.Fatal("Insufficient permissions to create native TUN interface: %v", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Create WireGuard service
|
||||
wgService, err = wgnetstack.NewWireGuardService(interfaceName, port, mtuInt, host, id, client, dns, useNativeInterface)
|
||||
if err != nil {
|
||||
logger.Fatal("Failed to create WireGuard service: %v", err)
|
||||
}
|
||||
|
||||
client.OnTokenUpdate(func(token string) {
|
||||
wgService.SetToken(token)
|
||||
})
|
||||
|
||||
ready = true
|
||||
}
|
||||
|
||||
func setDownstreamTNetstack(tnet *netstack.Net) {
|
||||
if wgService != nil {
|
||||
wgService.SetOthertnet(tnet)
|
||||
}
|
||||
}
|
||||
|
||||
func closeClients() {
|
||||
logger.Info("Closing clients...")
|
||||
if wgService != nil {
|
||||
wgService.Close()
|
||||
wgService = nil
|
||||
}
|
||||
}
|
||||
|
||||
func clientsHandleNewtConnection(publicKey string, endpoint string, relayPort uint16) {
|
||||
if !ready {
|
||||
return
|
||||
}
|
||||
|
||||
// split off the port from the endpoint
|
||||
parts := strings.Split(endpoint, ":")
|
||||
if len(parts) < 2 {
|
||||
logger.Error("Invalid endpoint format: %s", endpoint)
|
||||
return
|
||||
}
|
||||
endpoint = strings.Join(parts[:len(parts)-1], ":")
|
||||
|
||||
if wgService != nil {
|
||||
wgService.StartHolepunch(publicKey, endpoint, relayPort)
|
||||
}
|
||||
}
|
||||
|
||||
func clientsOnConnect() {
|
||||
if !ready {
|
||||
return
|
||||
}
|
||||
if wgService != nil {
|
||||
wgService.LoadRemoteConfig()
|
||||
}
|
||||
}
|
||||
|
||||
// clientsStartDirectRelay starts a direct UDP relay from the main tunnel netstack
|
||||
// to the clients' WireGuard, bypassing the proxy for better performance.
|
||||
func clientsStartDirectRelay(tunnelIP string) {
|
||||
if !ready {
|
||||
return
|
||||
}
|
||||
if wgService != nil {
|
||||
if err := wgService.StartDirectUDPRelay(tunnelIP); err != nil {
|
||||
logger.Error("Failed to start direct UDP relay: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,14 +13,12 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/fosrl/newt/bind"
|
||||
newtDevice "github.com/fosrl/newt/device"
|
||||
"github.com/fosrl/newt/holepunch"
|
||||
"github.com/fosrl/newt/logger"
|
||||
"github.com/fosrl/newt/nativessh"
|
||||
"github.com/fosrl/newt/netstack2"
|
||||
"github.com/fosrl/newt/network"
|
||||
"github.com/fosrl/newt/util"
|
||||
@@ -110,18 +108,12 @@ type WireGuardService struct {
|
||||
sharedBind *bind.SharedBind
|
||||
holePunchManager *holepunch.Manager
|
||||
useNativeInterface bool
|
||||
// SSH server running on the clients' netstack
|
||||
sshServer *sshServerHandle
|
||||
credStore *nativessh.CredentialStore
|
||||
// Direct UDP relay from main tunnel to clients' WireGuard
|
||||
directRelayStop chan struct{}
|
||||
directRelayWg sync.WaitGroup
|
||||
netstackListener net.PacketConn
|
||||
netstackListenerMu sync.Mutex
|
||||
wgTesterServer *wgtester.Server
|
||||
|
||||
// connection blocking: when true, all new incoming connections are dropped
|
||||
blocked atomic.Bool
|
||||
}
|
||||
|
||||
// generateChainId generates a random chain ID for deduplicating round-trip messages.
|
||||
@@ -196,17 +188,11 @@ func NewWireGuardService(interfaceName string, port uint16, mtu int, host string
|
||||
wsClient.RegisterHandler("newt/wg/targets/add", service.handleAddTarget)
|
||||
wsClient.RegisterHandler("newt/wg/targets/remove", service.handleRemoveTarget)
|
||||
wsClient.RegisterHandler("newt/wg/targets/update", service.handleUpdateTarget)
|
||||
wsClient.RegisterHandler("newt/wg/sync", service.handleSyncConfig)
|
||||
|
||||
return service, nil
|
||||
}
|
||||
|
||||
// SetCredentialStore sets the in-memory SSH credential store used by the
|
||||
// native SSH server. It must be called before the netstack is configured
|
||||
// (i.e. before the first newt/wg/receive-config message is processed).
|
||||
func (s *WireGuardService) SetCredentialStore(store *nativessh.CredentialStore) {
|
||||
s.credStore = store
|
||||
}
|
||||
|
||||
// ReportRTT allows reporting native RTTs to telemetry, rate-limited externally.
|
||||
func (s *WireGuardService) ReportRTT(seconds float64) {
|
||||
if s.serverPubKey == "" {
|
||||
@@ -225,12 +211,6 @@ func (s *WireGuardService) Close() {
|
||||
s.stopGetConfig = nil
|
||||
}
|
||||
|
||||
// Stop SSH server before tearing down the netstack
|
||||
if s.sshServer != nil {
|
||||
s.sshServer.stop()
|
||||
s.sshServer = nil
|
||||
}
|
||||
|
||||
// Flush access logs before tearing down the tunnel
|
||||
if s.tnet != nil {
|
||||
if ph := s.tnet.GetProxyHandler(); ph != nil {
|
||||
@@ -306,21 +286,6 @@ func (s *WireGuardService) GetPublicKey() wgtypes.Key {
|
||||
return s.key.PublicKey()
|
||||
}
|
||||
|
||||
// SetBlocked enables or disables connection blocking for this WireGuard service.
|
||||
// The state is persisted and applied immediately to any active proxy handler.
|
||||
func (s *WireGuardService) SetBlocked(v bool) {
|
||||
s.blocked.Store(v)
|
||||
s.mu.Lock()
|
||||
tnet := s.tnet
|
||||
s.mu.Unlock()
|
||||
if tnet == nil {
|
||||
return
|
||||
}
|
||||
if ph := tnet.GetProxyHandler(); ph != nil {
|
||||
ph.SetBlocked(v)
|
||||
}
|
||||
}
|
||||
|
||||
// SetOnNetstackReady sets a callback function to be called when the netstack interface is ready
|
||||
func (s *WireGuardService) SetOnNetstackReady(callback func(*netstack2.Net)) {
|
||||
s.onNetstackReady = callback
|
||||
@@ -567,15 +532,37 @@ func (s *WireGuardService) handleConfig(msg websocket.WSMessage) {
|
||||
logger.Info("Client connectivity setup. Ready to accept connections from clients!")
|
||||
}
|
||||
|
||||
// Sync synchronizes the clients WireGuard peers and targets with the desired state
|
||||
// received as part of the main newt/sync message.
|
||||
func (s *WireGuardService) Sync(peers []Peer, targets []Target) {
|
||||
if err := s.syncPeers(peers); err != nil {
|
||||
logger.Error("Failed to sync client peers: %v", err)
|
||||
// SyncConfig represents the configuration sent from server for syncing
|
||||
type SyncConfig struct {
|
||||
Targets []Target `json:"targets"`
|
||||
Peers []Peer `json:"peers"`
|
||||
}
|
||||
|
||||
func (s *WireGuardService) handleSyncConfig(msg websocket.WSMessage) {
|
||||
var syncConfig SyncConfig
|
||||
|
||||
logger.Debug("Received sync message: %v", msg)
|
||||
logger.Info("Received sync configuration from remote server")
|
||||
|
||||
jsonData, err := json.Marshal(msg.Data)
|
||||
if err != nil {
|
||||
logger.Error("Error marshaling sync data: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.syncTargets(targets); err != nil {
|
||||
logger.Error("Failed to sync client targets: %v", err)
|
||||
if err := json.Unmarshal(jsonData, &syncConfig); err != nil {
|
||||
logger.Error("Error unmarshaling sync data: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Sync peers
|
||||
if err := s.syncPeers(syncConfig.Peers); err != nil {
|
||||
logger.Error("Failed to sync peers: %v", err)
|
||||
}
|
||||
|
||||
// Sync targets
|
||||
if err := s.syncTargets(syncConfig.Targets); err != nil {
|
||||
logger.Error("Failed to sync targets: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -642,12 +629,8 @@ func (s *WireGuardService) syncPeers(desiredPeers []Peer) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// syncTargets synchronizes the current targets with the desired state.
|
||||
// A sync represents the full authoritative state from the server, so rather
|
||||
// than diffing against the currently installed rules (which can miss
|
||||
// changes to a rule's contents when its source/dest prefix key is
|
||||
// unchanged - e.g. a RewriteTo update), we just rebuild the entire rule set
|
||||
// from scratch on every sync. This guarantees no stale rule can survive.
|
||||
// syncTargets synchronizes the current targets with the desired state
|
||||
// It removes targets not in the desired list and adds missing ones
|
||||
func (s *WireGuardService) syncTargets(desiredTargets []Target) error {
|
||||
if s.tnet == nil {
|
||||
// Native interface mode - proxy features not available, skip silently
|
||||
@@ -655,31 +638,70 @@ func (s *WireGuardService) syncTargets(desiredTargets []Target) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
var rules []netstack2.SubnetRule
|
||||
// Get current rules from the proxy handler
|
||||
currentRules := s.tnet.GetProxySubnetRules()
|
||||
|
||||
// Build a map of current rules by source+dest prefix
|
||||
type ruleKey struct {
|
||||
sourcePrefix string
|
||||
destPrefix string
|
||||
}
|
||||
currentRuleMap := make(map[ruleKey]bool)
|
||||
for _, rule := range currentRules {
|
||||
key := ruleKey{
|
||||
sourcePrefix: rule.SourcePrefix.String(),
|
||||
destPrefix: rule.DestPrefix.String(),
|
||||
}
|
||||
currentRuleMap[key] = true
|
||||
}
|
||||
|
||||
// Build a map of desired targets
|
||||
desiredTargetMap := make(map[ruleKey]Target)
|
||||
for _, target := range desiredTargets {
|
||||
destPrefix, err := netip.ParsePrefix(target.DestPrefix)
|
||||
if err != nil {
|
||||
logger.Warn("Invalid dest prefix %s during sync: %v", target.DestPrefix, err)
|
||||
continue
|
||||
key := ruleKey{
|
||||
sourcePrefix: target.SourcePrefix,
|
||||
destPrefix: target.DestPrefix,
|
||||
}
|
||||
desiredTargetMap[key] = target
|
||||
}
|
||||
|
||||
var portRanges []netstack2.PortRange
|
||||
for _, pr := range target.PortRange {
|
||||
portRanges = append(portRanges, netstack2.PortRange{
|
||||
Min: pr.Min,
|
||||
Max: pr.Max,
|
||||
Protocol: pr.Protocol,
|
||||
})
|
||||
// Remove targets that are not in the desired list
|
||||
for _, rule := range currentRules {
|
||||
key := ruleKey{
|
||||
sourcePrefix: rule.SourcePrefix.String(),
|
||||
destPrefix: rule.DestPrefix.String(),
|
||||
}
|
||||
if _, exists := desiredTargetMap[key]; !exists {
|
||||
s.tnet.RemoveProxySubnetRule(rule.SourcePrefix, rule.DestPrefix)
|
||||
logger.Info("Removed target %s -> %s during sync", rule.SourcePrefix.String(), rule.DestPrefix.String())
|
||||
}
|
||||
}
|
||||
|
||||
for _, sp := range resolveSourcePrefixes(target) {
|
||||
sourcePrefix, err := netip.ParsePrefix(sp)
|
||||
// Add targets that are missing
|
||||
for key, target := range desiredTargetMap {
|
||||
if _, exists := currentRuleMap[key]; !exists {
|
||||
sourcePrefix, err := netip.ParsePrefix(target.SourcePrefix)
|
||||
if err != nil {
|
||||
logger.Warn("Invalid source prefix %s during sync: %v", sp, err)
|
||||
logger.Warn("Invalid source prefix %s during sync: %v", target.SourcePrefix, err)
|
||||
continue
|
||||
}
|
||||
|
||||
rules = append(rules, netstack2.SubnetRule{
|
||||
destPrefix, err := netip.ParsePrefix(target.DestPrefix)
|
||||
if err != nil {
|
||||
logger.Warn("Invalid dest prefix %s during sync: %v", target.DestPrefix, err)
|
||||
continue
|
||||
}
|
||||
|
||||
var portRanges []netstack2.PortRange
|
||||
for _, pr := range target.PortRange {
|
||||
portRanges = append(portRanges, netstack2.PortRange{
|
||||
Min: pr.Min,
|
||||
Max: pr.Max,
|
||||
Protocol: pr.Protocol,
|
||||
})
|
||||
}
|
||||
|
||||
s.tnet.AddProxySubnetRule(netstack2.SubnetRule{
|
||||
SourcePrefix: sourcePrefix,
|
||||
DestPrefix: destPrefix,
|
||||
RewriteTo: target.RewriteTo,
|
||||
@@ -691,12 +713,10 @@ func (s *WireGuardService) syncTargets(desiredTargets []Target) error {
|
||||
TLSCert: target.TLSCert,
|
||||
TLSKey: target.TLSKey,
|
||||
})
|
||||
logger.Info("Added target %s -> %s during sync", target.SourcePrefix, target.DestPrefix)
|
||||
}
|
||||
}
|
||||
|
||||
s.tnet.ReplaceProxySubnetRules(rules)
|
||||
logger.Info("Synced targets: %d rules installed", len(rules))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -870,25 +890,7 @@ func (s *WireGuardService) ensureWireguardInterface(wgconfig WgConfig) error {
|
||||
logger.Error("Failed to start WireGuard tester server: %v", err)
|
||||
}
|
||||
|
||||
// Start the SSH server on the clients' netstack (port 22).
|
||||
// A nil credStore means SSH is disabled (--disable-ssh), so skip starting the server.
|
||||
if s.credStore != nil {
|
||||
if h, sshErr := startSSHOnNetstack(s.tnet, s.credStore); sshErr != nil {
|
||||
logger.Warn("nativessh: not starting SSH server on clients netstack: %v", sshErr)
|
||||
} else {
|
||||
s.sshServer = h
|
||||
}
|
||||
}
|
||||
|
||||
// Note: we already unlocked above, so don't use defer unlock
|
||||
|
||||
// Apply any pending blocked state to the newly created proxy handler
|
||||
if s.blocked.Load() {
|
||||
if ph := s.tnet.GetProxyHandler(); ph != nil {
|
||||
ph.SetBlocked(true)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1561,33 +1563,3 @@ func (s *WireGuardService) filterReadOnlyFields(config string) string {
|
||||
|
||||
return strings.Join(filteredLines, "\n")
|
||||
}
|
||||
|
||||
// sshServerHandle holds the listener so the SSH server can be stopped by
|
||||
// closing it.
|
||||
type sshServerHandle struct {
|
||||
ln net.Listener
|
||||
}
|
||||
|
||||
func (h *sshServerHandle) stop() {
|
||||
_ = h.ln.Close()
|
||||
}
|
||||
|
||||
// startSSHOnNetstack creates a TCP listener on port 22 of the clients' netstack
|
||||
// and starts serving SSH connections on it in the background. The returned
|
||||
// handle can be used to stop the server by closing the listener.
|
||||
func startSSHOnNetstack(tnet *netstack2.Net, creds *nativessh.CredentialStore) (*sshServerHandle, error) {
|
||||
srv := nativessh.NewServer(nativessh.ServerConfig{
|
||||
Credentials: creds,
|
||||
})
|
||||
ln, err := tnet.ListenTCP(&net.TCPAddr{Port: 22})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("listen on netstack port 22: %w", err)
|
||||
}
|
||||
h := &sshServerHandle{ln: ln}
|
||||
go func() {
|
||||
if err := srv.Serve(ln); err != nil {
|
||||
logger.Debug("nativessh: clients netstack server stopped: %v", err)
|
||||
}
|
||||
}()
|
||||
return h, nil
|
||||
}
|
||||
|
||||
610
common.go
Normal file
610
common.go
Normal file
@@ -0,0 +1,610 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"os/exec"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"math/rand"
|
||||
|
||||
"github.com/fosrl/newt/internal/telemetry"
|
||||
"github.com/fosrl/newt/logger"
|
||||
"github.com/fosrl/newt/proxy"
|
||||
"github.com/fosrl/newt/websocket"
|
||||
"golang.org/x/net/icmp"
|
||||
"golang.org/x/net/ipv4"
|
||||
"golang.zx2c4.com/wireguard/tun/netstack"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
const msgHealthFileWriteFailed = "Failed to write health file: %v"
|
||||
|
||||
func ping(tnet *netstack.Net, dst string, timeout time.Duration) (time.Duration, error) {
|
||||
// logger.Debug("Pinging %s", dst)
|
||||
socket, err := tnet.Dial("ping4", dst)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to create ICMP socket: %w", err)
|
||||
}
|
||||
defer socket.Close()
|
||||
|
||||
// Set socket buffer sizes to handle high bandwidth scenarios
|
||||
if tcpConn, ok := socket.(interface{ SetReadBuffer(int) error }); ok {
|
||||
tcpConn.SetReadBuffer(64 * 1024)
|
||||
}
|
||||
if tcpConn, ok := socket.(interface{ SetWriteBuffer(int) error }); ok {
|
||||
tcpConn.SetWriteBuffer(64 * 1024)
|
||||
}
|
||||
|
||||
requestPing := icmp.Echo{
|
||||
Seq: rand.Intn(1 << 16),
|
||||
Data: []byte("newtping"),
|
||||
}
|
||||
|
||||
icmpBytes, err := (&icmp.Message{Type: ipv4.ICMPTypeEcho, Code: 0, Body: &requestPing}).Marshal(nil)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to marshal ICMP message: %w", err)
|
||||
}
|
||||
|
||||
if err := socket.SetReadDeadline(time.Now().Add(timeout)); err != nil {
|
||||
return 0, fmt.Errorf("failed to set read deadline: %w", err)
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
_, err = socket.Write(icmpBytes)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to write ICMP packet: %w", err)
|
||||
}
|
||||
|
||||
// Use larger buffer for reading to handle potential network congestion
|
||||
readBuffer := make([]byte, 1500)
|
||||
n, err := socket.Read(readBuffer)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to read ICMP packet: %w", err)
|
||||
}
|
||||
|
||||
replyPacket, err := icmp.ParseMessage(1, readBuffer[:n])
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to parse ICMP packet: %w", err)
|
||||
}
|
||||
|
||||
replyPing, ok := replyPacket.Body.(*icmp.Echo)
|
||||
if !ok {
|
||||
return 0, fmt.Errorf("invalid reply type: got %T, want *icmp.Echo", replyPacket.Body)
|
||||
}
|
||||
|
||||
if !bytes.Equal(replyPing.Data, requestPing.Data) || replyPing.Seq != requestPing.Seq {
|
||||
return 0, fmt.Errorf("invalid ping reply: got seq=%d data=%q, want seq=%d data=%q",
|
||||
replyPing.Seq, replyPing.Data, requestPing.Seq, requestPing.Data)
|
||||
}
|
||||
|
||||
latency := time.Since(start)
|
||||
|
||||
// logger.Debug("Ping to %s successful, latency: %v", dst, latency)
|
||||
|
||||
return latency, nil
|
||||
}
|
||||
|
||||
// reliablePing performs multiple ping attempts with adaptive timeout
|
||||
func reliablePing(tnet *netstack.Net, dst string, baseTimeout time.Duration, maxAttempts int) (time.Duration, error) {
|
||||
var lastErr error
|
||||
var totalLatency time.Duration
|
||||
successCount := 0
|
||||
|
||||
for attempt := 1; attempt <= maxAttempts; attempt++ {
|
||||
// Adaptive timeout: increase timeout for later attempts
|
||||
timeout := baseTimeout + time.Duration(attempt-1)*500*time.Millisecond
|
||||
|
||||
// Add jitter to prevent thundering herd
|
||||
jitter := time.Duration(rand.Intn(100)) * time.Millisecond
|
||||
timeout += jitter
|
||||
|
||||
latency, err := ping(tnet, dst, timeout)
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
logger.Debug("Ping attempt %d/%d failed: %v", attempt, maxAttempts, err)
|
||||
|
||||
// Brief pause between attempts with exponential backoff
|
||||
if attempt < maxAttempts {
|
||||
backoff := time.Duration(attempt) * 50 * time.Millisecond
|
||||
time.Sleep(backoff)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
totalLatency += latency
|
||||
successCount++
|
||||
|
||||
// If we get at least one success, we can return early for health checks
|
||||
if successCount > 0 {
|
||||
avgLatency := totalLatency / time.Duration(successCount)
|
||||
// logger.Debug("Reliable ping succeeded after %d attempts, avg latency: %v", attempt, avgLatency)
|
||||
return avgLatency, nil
|
||||
}
|
||||
}
|
||||
|
||||
if successCount == 0 {
|
||||
return 0, fmt.Errorf("all %d ping attempts failed, last error: %v", maxAttempts, lastErr)
|
||||
}
|
||||
|
||||
return totalLatency / time.Duration(successCount), nil
|
||||
}
|
||||
|
||||
func pingWithRetry(tnet *netstack.Net, dst string, timeout time.Duration) (stopChan chan struct{}, err error) {
|
||||
|
||||
if healthFile != "" {
|
||||
err = os.Remove(healthFile)
|
||||
if err != nil {
|
||||
logger.Error("Failed to remove health file: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
const (
|
||||
initialMaxAttempts = 5
|
||||
initialRetryDelay = 2 * time.Second
|
||||
maxRetryDelay = 60 * time.Second // Cap the maximum delay
|
||||
)
|
||||
|
||||
stopChan = make(chan struct{})
|
||||
attempt := 1
|
||||
retryDelay := initialRetryDelay
|
||||
|
||||
// First try with the initial parameters
|
||||
logger.Debug("Ping attempt %d", attempt)
|
||||
if latency, err := ping(tnet, dst, timeout); err == nil {
|
||||
// Successful ping
|
||||
logger.Debug("Ping latency: %v", latency)
|
||||
logger.Info("Tunnel connection to server established successfully!")
|
||||
if healthFile != "" {
|
||||
err := os.WriteFile(healthFile, []byte("ok"), 0644)
|
||||
if err != nil {
|
||||
logger.Warn(msgHealthFileWriteFailed, err)
|
||||
}
|
||||
}
|
||||
return stopChan, nil
|
||||
} else {
|
||||
logger.Warn("Ping attempt %d failed: %v", attempt, err)
|
||||
}
|
||||
|
||||
// Start a goroutine that will attempt pings indefinitely with increasing delays
|
||||
go func() {
|
||||
attempt = 2 // Continue from attempt 2
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-stopChan:
|
||||
return
|
||||
default:
|
||||
logger.Debug("Ping attempt %d", attempt)
|
||||
|
||||
if latency, err := ping(tnet, dst, timeout); err != nil {
|
||||
logger.Warn("Ping attempt %d failed: %v", attempt, err)
|
||||
|
||||
// Increase delay after certain thresholds but cap it
|
||||
if attempt%5 == 0 && retryDelay < maxRetryDelay {
|
||||
retryDelay = time.Duration(float64(retryDelay) * 1.5)
|
||||
if retryDelay > maxRetryDelay {
|
||||
retryDelay = maxRetryDelay
|
||||
}
|
||||
logger.Info("Increasing ping retry delay to %v", retryDelay)
|
||||
}
|
||||
|
||||
time.Sleep(retryDelay)
|
||||
attempt++
|
||||
} else {
|
||||
// Successful ping
|
||||
logger.Debug("Ping succeeded after %d attempts", attempt)
|
||||
logger.Debug("Ping latency: %v", latency)
|
||||
logger.Info("Tunnel connection to server established successfully!")
|
||||
if healthFile != "" {
|
||||
err := os.WriteFile(healthFile, []byte("ok"), 0644)
|
||||
if err != nil {
|
||||
logger.Warn(msgHealthFileWriteFailed, err)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
case <-pingStopChan:
|
||||
// Stop the goroutine when signaled
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// Return an error for the first batch of attempts (to maintain compatibility with existing code)
|
||||
return stopChan, fmt.Errorf("initial ping attempts failed, continuing in background")
|
||||
}
|
||||
|
||||
// shouldFireRecovery decides whether the data-plane recovery flow in
|
||||
// startPingCheck should run on this tick. Recovery fires once when the
|
||||
// consecutive-failure counter first crosses the threshold; the connectionLost
|
||||
// flag prevents re-firing until a successful ping resets the state.
|
||||
//
|
||||
// This condition was previously inlined into startPingCheck and AND-ed with
|
||||
// `currentInterval < maxInterval`, which silently broke recovery once
|
||||
// pingInterval's default was bumped to 15s while maxInterval stayed at 6s
|
||||
// (commit 8161fa6, March 2026): the gate became permanently false on default
|
||||
// settings, so the recovery code never executed and ping failures climbed
|
||||
// forever — the proximate cause of fosrl/newt#284, #310 and pangolin#1004.
|
||||
//
|
||||
// Recovery and backoff are independent concerns; the backoff ramp is now
|
||||
// computed separately in the caller. Do not re-introduce currentInterval
|
||||
// here.
|
||||
func shouldFireRecovery(consecutiveFailures, failureThreshold int, connectionLost bool) bool {
|
||||
return consecutiveFailures >= failureThreshold && !connectionLost
|
||||
}
|
||||
|
||||
func startPingCheck(tnet *netstack.Net, serverIP string, client *websocket.Client, tunnelID string) chan struct{} {
|
||||
maxInterval := 6 * time.Second
|
||||
currentInterval := pingInterval
|
||||
consecutiveFailures := 0
|
||||
connectionLost := false
|
||||
|
||||
// Track recent latencies for adaptive timeout calculation
|
||||
recentLatencies := make([]time.Duration, 0, 10)
|
||||
|
||||
pingStopChan := make(chan struct{})
|
||||
|
||||
go func() {
|
||||
ticker := time.NewTicker(currentInterval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
// Calculate adaptive timeout based on recent latencies
|
||||
adaptiveTimeout := pingTimeout
|
||||
if len(recentLatencies) > 0 {
|
||||
var sum time.Duration
|
||||
for _, lat := range recentLatencies {
|
||||
sum += lat
|
||||
}
|
||||
avgLatency := sum / time.Duration(len(recentLatencies))
|
||||
// Use 3x average latency as timeout, with minimum of pingTimeout
|
||||
adaptiveTimeout = avgLatency * 3
|
||||
if adaptiveTimeout < pingTimeout {
|
||||
adaptiveTimeout = pingTimeout
|
||||
}
|
||||
if adaptiveTimeout > 15*time.Second {
|
||||
adaptiveTimeout = 15 * time.Second
|
||||
}
|
||||
}
|
||||
|
||||
// Use reliable ping with multiple attempts
|
||||
maxAttempts := 2
|
||||
if consecutiveFailures > 4 {
|
||||
maxAttempts = 4 // More attempts when connection is unstable
|
||||
}
|
||||
|
||||
latency, err := reliablePing(tnet, serverIP, adaptiveTimeout, maxAttempts)
|
||||
if err != nil {
|
||||
consecutiveFailures++
|
||||
|
||||
// Track recent latencies (add a high value for failures)
|
||||
recentLatencies = append(recentLatencies, adaptiveTimeout)
|
||||
if len(recentLatencies) > 10 {
|
||||
recentLatencies = recentLatencies[1:]
|
||||
}
|
||||
|
||||
if consecutiveFailures < 2 {
|
||||
logger.Debug("Periodic ping failed (%d consecutive failures): %v", consecutiveFailures, err)
|
||||
} else {
|
||||
logger.Warn("Periodic ping failed (%d consecutive failures): %v", consecutiveFailures, err)
|
||||
}
|
||||
|
||||
// More lenient threshold for declaring connection lost under load
|
||||
failureThreshold := 4
|
||||
if shouldFireRecovery(consecutiveFailures, failureThreshold, connectionLost) {
|
||||
connectionLost = true
|
||||
logger.Warn("Connection to server lost after %d failures. Continuous reconnection attempts will be made.", consecutiveFailures)
|
||||
if tunnelID != "" {
|
||||
telemetry.IncReconnect(context.Background(), tunnelID, "client", telemetry.ReasonTimeout)
|
||||
}
|
||||
pingChainId := generateChainId()
|
||||
pendingPingChainId = pingChainId
|
||||
stopFunc = client.SendMessageInterval("newt/ping/request", map[string]interface{}{
|
||||
"chainId": pingChainId,
|
||||
}, 3*time.Second)
|
||||
// Send registration message to the server for backward compatibility
|
||||
bcChainId := generateChainId()
|
||||
pendingRegisterChainId = bcChainId
|
||||
err := client.SendMessage("newt/wg/register", map[string]interface{}{
|
||||
"publicKey": publicKey.String(),
|
||||
"backwardsCompatible": true,
|
||||
"chainId": bcChainId,
|
||||
})
|
||||
if err != nil {
|
||||
logger.Error("Failed to send registration message: %v", err)
|
||||
}
|
||||
if healthFile != "" {
|
||||
err = os.Remove(healthFile)
|
||||
if err != nil {
|
||||
logger.Error("Failed to remove health file: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Backoff: ramp the periodic-ping interval up while we are
|
||||
// past the failure threshold, capped at maxInterval. Kept
|
||||
// independent of the recovery trigger above so the trigger
|
||||
// fires on every outage regardless of pingInterval.
|
||||
if consecutiveFailures >= failureThreshold && currentInterval < maxInterval {
|
||||
currentInterval = time.Duration(float64(currentInterval) * 1.3)
|
||||
if currentInterval > maxInterval {
|
||||
currentInterval = maxInterval
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Track recent latencies
|
||||
recentLatencies = append(recentLatencies, latency)
|
||||
// Record tunnel latency (limit sampling to this periodic check)
|
||||
if tunnelID != "" {
|
||||
telemetry.ObserveTunnelLatency(context.Background(), tunnelID, "wireguard", latency.Seconds())
|
||||
}
|
||||
if len(recentLatencies) > 10 {
|
||||
recentLatencies = recentLatencies[1:]
|
||||
}
|
||||
|
||||
if connectionLost {
|
||||
connectionLost = false
|
||||
logger.Info("Connection to server restored after %d failures!", consecutiveFailures)
|
||||
if healthFile != "" {
|
||||
err := os.WriteFile(healthFile, []byte("ok"), 0644)
|
||||
if err != nil {
|
||||
logger.Warn("Failed to write health file: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
if currentInterval > pingInterval {
|
||||
currentInterval = time.Duration(float64(currentInterval) * 0.9) // Slower decrease
|
||||
if currentInterval < pingInterval {
|
||||
currentInterval = pingInterval
|
||||
}
|
||||
ticker.Reset(currentInterval)
|
||||
logger.Debug("Decreased ping check interval to %v after successful ping", currentInterval)
|
||||
}
|
||||
consecutiveFailures = 0
|
||||
}
|
||||
case <-pingStopChan:
|
||||
logger.Info("Stopping ping check")
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
return pingStopChan
|
||||
}
|
||||
|
||||
func parseTargetData(data interface{}) (TargetData, error) {
|
||||
var targetData TargetData
|
||||
jsonData, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
logger.Info("Error marshaling data: %v", err)
|
||||
return targetData, err
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(jsonData, &targetData); err != nil {
|
||||
logger.Info("Error unmarshaling target data: %v", err)
|
||||
return targetData, err
|
||||
}
|
||||
return targetData, nil
|
||||
}
|
||||
|
||||
// parseTargetString parses a target string in the format "listenPort:host:targetPort"
|
||||
// It properly handles IPv6 addresses which must be in brackets: "listenPort:[ipv6]:targetPort"
|
||||
// Examples:
|
||||
// - IPv4: "3001:192.168.1.1:80"
|
||||
// - IPv6: "3001:[::1]:8080" or "3001:[fd70:1452:b736:4dd5:caca:7db9:c588:f5b3]:80"
|
||||
//
|
||||
// Returns listenPort, targetAddress (in host:port format suitable for net.Dial), and error
|
||||
func parseTargetString(target string) (int, string, error) {
|
||||
// Find the first colon to extract the listen port
|
||||
firstColon := strings.Index(target, ":")
|
||||
if firstColon == -1 {
|
||||
return 0, "", fmt.Errorf("invalid target format, no colon found: %s", target)
|
||||
}
|
||||
|
||||
listenPortStr := target[:firstColon]
|
||||
var listenPort int
|
||||
_, err := fmt.Sscanf(listenPortStr, "%d", &listenPort)
|
||||
if err != nil {
|
||||
return 0, "", fmt.Errorf("invalid listen port: %s", listenPortStr)
|
||||
}
|
||||
if listenPort <= 0 || listenPort > 65535 {
|
||||
return 0, "", fmt.Errorf("listen port out of range: %d", listenPort)
|
||||
}
|
||||
|
||||
// The remainder is host:targetPort - use net.SplitHostPort which handles IPv6 brackets
|
||||
remainder := target[firstColon+1:]
|
||||
host, targetPort, err := net.SplitHostPort(remainder)
|
||||
if err != nil {
|
||||
return 0, "", fmt.Errorf("invalid host:port format '%s': %w", remainder, err)
|
||||
}
|
||||
|
||||
// Reject empty host or target port
|
||||
if host == "" {
|
||||
return 0, "", fmt.Errorf("empty host in target: %s", target)
|
||||
}
|
||||
if targetPort == "" {
|
||||
return 0, "", fmt.Errorf("empty target port in target: %s", target)
|
||||
}
|
||||
|
||||
// Reconstruct the target address using JoinHostPort (handles IPv6 properly)
|
||||
targetAddr := net.JoinHostPort(host, targetPort)
|
||||
|
||||
return listenPort, targetAddr, nil
|
||||
}
|
||||
|
||||
func updateTargets(pm *proxy.ProxyManager, action string, tunnelIP string, proto string, targetData TargetData) error {
|
||||
for _, t := range targetData.Targets {
|
||||
// Parse the target string, handling both IPv4 and IPv6 addresses
|
||||
port, target, err := parseTargetString(t)
|
||||
if err != nil {
|
||||
logger.Info("Invalid target format: %s (%v)", t, err)
|
||||
continue
|
||||
}
|
||||
|
||||
switch action {
|
||||
case "add":
|
||||
// Call updown script if provided
|
||||
processedTarget := target
|
||||
if updownScript != "" {
|
||||
newTarget, err := executeUpdownScript(action, proto, target)
|
||||
if err != nil {
|
||||
logger.Warn("Updown script error: %v", err)
|
||||
} else if newTarget != "" {
|
||||
processedTarget = newTarget
|
||||
}
|
||||
}
|
||||
|
||||
// Only remove the specific target if it exists
|
||||
err := pm.RemoveTarget(proto, tunnelIP, port)
|
||||
if err != nil {
|
||||
// Ignore "target not found" errors as this is expected for new targets
|
||||
if !strings.Contains(err.Error(), "target not found") {
|
||||
logger.Error("Failed to remove existing target: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Add the new target
|
||||
pm.AddTarget(proto, tunnelIP, port, processedTarget)
|
||||
|
||||
case "remove":
|
||||
logger.Info("Removing target with port %d", port)
|
||||
|
||||
// Call updown script if provided
|
||||
if updownScript != "" {
|
||||
_, err := executeUpdownScript(action, proto, target)
|
||||
if err != nil {
|
||||
logger.Warn("Updown script error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
err = pm.RemoveTarget(proto, tunnelIP, port)
|
||||
if err != nil {
|
||||
logger.Error("Failed to remove target: %v", err)
|
||||
return err
|
||||
}
|
||||
default:
|
||||
logger.Info("Unknown action: %s", action)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func executeUpdownScript(action, proto, target string) (string, error) {
|
||||
if updownScript == "" {
|
||||
return target, nil
|
||||
}
|
||||
|
||||
// Split the updownScript in case it contains spaces (like "/usr/bin/python3 script.py")
|
||||
parts := strings.Fields(updownScript)
|
||||
if len(parts) == 0 {
|
||||
return target, fmt.Errorf("invalid updown script command")
|
||||
}
|
||||
|
||||
var cmd *exec.Cmd
|
||||
if len(parts) == 1 {
|
||||
// If it's a single executable
|
||||
logger.Info("Executing updown script: %s %s %s %s", updownScript, action, proto, target)
|
||||
cmd = exec.Command(parts[0], action, proto, target)
|
||||
} else {
|
||||
// If it includes interpreter and script
|
||||
args := append(parts[1:], action, proto, target)
|
||||
logger.Info("Executing updown script: %s %s %s %s %s", parts[0], strings.Join(parts[1:], " "), action, proto, target)
|
||||
cmd = exec.Command(parts[0], args...)
|
||||
}
|
||||
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
if exitErr, ok := err.(*exec.ExitError); ok {
|
||||
return "", fmt.Errorf("updown script execution failed (exit code %d): %s",
|
||||
exitErr.ExitCode(), string(exitErr.Stderr))
|
||||
}
|
||||
return "", fmt.Errorf("updown script execution failed: %v", err)
|
||||
}
|
||||
|
||||
// If the script returns a new target, use it
|
||||
newTarget := strings.TrimSpace(string(output))
|
||||
if newTarget != "" {
|
||||
logger.Info("Updown script returned new target: %s", newTarget)
|
||||
return newTarget, nil
|
||||
}
|
||||
|
||||
return target, nil
|
||||
}
|
||||
|
||||
// interpolateBlueprint finds all {{...}} tokens in the raw blueprint bytes and
|
||||
// replaces recognised schemes with their resolved values. Currently supported:
|
||||
//
|
||||
// - env.<VAR> – replaced with the value of the named environment variable
|
||||
//
|
||||
// Any token that does not match a supported scheme is left as-is so that
|
||||
// future schemes (e.g. tag., api.) are preserved rather than silently dropped.
|
||||
func interpolateBlueprint(data []byte) []byte {
|
||||
re := regexp.MustCompile(`\{\{([^}]+)\}\}`)
|
||||
return re.ReplaceAllFunc(data, func(match []byte) []byte {
|
||||
// strip the surrounding {{ }}
|
||||
inner := strings.TrimSpace(string(match[2 : len(match)-2]))
|
||||
|
||||
if strings.HasPrefix(inner, "env.") {
|
||||
varName := strings.TrimPrefix(inner, "env.")
|
||||
return []byte(os.Getenv(varName))
|
||||
}
|
||||
|
||||
// unrecognised scheme – leave the token untouched
|
||||
return match
|
||||
})
|
||||
}
|
||||
|
||||
func sendBlueprint(client *websocket.Client, file string) error {
|
||||
if file == "" {
|
||||
return nil
|
||||
}
|
||||
// try to read the blueprint file
|
||||
blueprintData, err := os.ReadFile(file)
|
||||
if err != nil {
|
||||
logger.Error("Failed to read blueprint file: %v", err)
|
||||
} else {
|
||||
// interpolate {{env.VAR}} (and any future schemes) before parsing
|
||||
blueprintData = interpolateBlueprint(blueprintData)
|
||||
|
||||
// first we should convert the yaml to json and error if the yaml is bad
|
||||
var yamlObj interface{}
|
||||
var blueprintJsonData string
|
||||
|
||||
err = yaml.Unmarshal(blueprintData, &yamlObj)
|
||||
if err != nil {
|
||||
logger.Error("Failed to parse blueprint YAML: %v", err)
|
||||
} else {
|
||||
// convert to json
|
||||
jsonBytes, err := json.Marshal(yamlObj)
|
||||
if err != nil {
|
||||
logger.Error("Failed to convert blueprint to JSON: %v", err)
|
||||
} else {
|
||||
blueprintJsonData = string(jsonBytes)
|
||||
logger.Debug("Converted blueprint to JSON: %s", blueprintJsonData)
|
||||
}
|
||||
}
|
||||
|
||||
// if we have valid json data, we can send it to the server
|
||||
if blueprintJsonData == "" {
|
||||
logger.Error("No valid blueprint JSON data to send to server")
|
||||
return nil
|
||||
}
|
||||
|
||||
logger.Info("Sending blueprint to server for application")
|
||||
|
||||
// send the blueprint data to the server
|
||||
err = client.SendMessage("newt/blueprint/apply", map[string]interface{}{
|
||||
"blueprint": blueprintJsonData,
|
||||
})
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -1,151 +1,10 @@
|
||||
package newt
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestWatchBlueprintFile_WriteTriggersSend(t *testing.T) {
|
||||
f, err := os.CreateTemp(t.TempDir(), "blueprint-*.yaml")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f.Close()
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
var calls atomic.Int32
|
||||
go watchBlueprintFile(ctx, f.Name(), func() error {
|
||||
calls.Add(1)
|
||||
return nil
|
||||
})
|
||||
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
|
||||
if err := os.WriteFile(f.Name(), []byte("content"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
time.Sleep(700 * time.Millisecond)
|
||||
|
||||
if calls.Load() != 1 {
|
||||
t.Errorf("expected 1 send call, got %d", calls.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestWatchBlueprintFile_DebounceCoalescesEvents(t *testing.T) {
|
||||
f, err := os.CreateTemp(t.TempDir(), "blueprint-*.yaml")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f.Close()
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
var calls atomic.Int32
|
||||
go watchBlueprintFile(ctx, f.Name(), func() error {
|
||||
calls.Add(1)
|
||||
return nil
|
||||
})
|
||||
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
|
||||
for i := 0; i < 5; i++ {
|
||||
if err := os.WriteFile(f.Name(), []byte("change"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
}
|
||||
|
||||
time.Sleep(700 * time.Millisecond)
|
||||
|
||||
if calls.Load() != 1 {
|
||||
t.Errorf("expected 1 send call after debounce, got %d", calls.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestWatchBlueprintFile_ContextCancellationStops(t *testing.T) {
|
||||
f, err := os.CreateTemp(t.TempDir(), "blueprint-*.yaml")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f.Close()
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
watchBlueprintFile(ctx, f.Name(), func() error { return nil })
|
||||
close(done)
|
||||
}()
|
||||
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
cancel()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Error("watchBlueprintFile did not exit after context cancellation")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWatchBlueprintFile_AtomicWriteTriggersSend(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
target := filepath.Join(dir, "blueprint.yaml")
|
||||
if err := os.WriteFile(target, []byte("initial"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
var calls atomic.Int32
|
||||
go watchBlueprintFile(ctx, target, func() error {
|
||||
calls.Add(1)
|
||||
return nil
|
||||
})
|
||||
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
|
||||
tmp := filepath.Join(dir, "blueprint.yaml.tmp")
|
||||
if err := os.WriteFile(tmp, []byte("updated"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Rename(tmp, target); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
time.Sleep(700 * time.Millisecond)
|
||||
|
||||
if calls.Load() < 1 {
|
||||
t.Errorf("expected at least 1 send call after atomic write, got %d", calls.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestWatchBlueprintFile_MissingFileReturnsGracefully(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
watchBlueprintFile(ctx, "/nonexistent/path/blueprint.yaml", func() error { return nil })
|
||||
close(done)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Error("watchBlueprintFile did not return for missing file")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTargetString(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -154,6 +13,7 @@ func TestParseTargetString(t *testing.T) {
|
||||
wantTargetAddr string
|
||||
wantErr bool
|
||||
}{
|
||||
// IPv4 test cases
|
||||
{
|
||||
name: "valid IPv4 basic",
|
||||
input: "3001:192.168.1.1:80",
|
||||
@@ -175,6 +35,8 @@ func TestParseTargetString(t *testing.T) {
|
||||
wantTargetAddr: "10.0.0.1:443",
|
||||
wantErr: false,
|
||||
},
|
||||
|
||||
// IPv6 test cases
|
||||
{
|
||||
name: "valid IPv6 loopback",
|
||||
input: "3001:[::1]:8080",
|
||||
@@ -210,6 +72,8 @@ func TestParseTargetString(t *testing.T) {
|
||||
wantTargetAddr: "[::ffff:192.168.1.1]:6000",
|
||||
wantErr: false,
|
||||
},
|
||||
|
||||
// Hostname test cases
|
||||
{
|
||||
name: "valid hostname",
|
||||
input: "8080:example.com:80",
|
||||
@@ -231,6 +95,8 @@ func TestParseTargetString(t *testing.T) {
|
||||
wantTargetAddr: "localhost:3000",
|
||||
wantErr: false,
|
||||
},
|
||||
|
||||
// Error cases
|
||||
{
|
||||
name: "invalid - no colons",
|
||||
input: "invalid",
|
||||
@@ -303,7 +169,7 @@ func TestParseTargetString(t *testing.T) {
|
||||
}
|
||||
|
||||
if tt.wantErr {
|
||||
return
|
||||
return // Don't check other values if we expected an error
|
||||
}
|
||||
|
||||
if listenPort != tt.wantListenPort {
|
||||
@@ -317,6 +183,7 @@ func TestParseTargetString(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseTargetStringNetDialCompatibility verifies that the output is compatible with net.Dial
|
||||
func TestParseTargetStringNetDialCompatibility(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -334,6 +201,8 @@ func TestParseTargetStringNetDialCompatibility(t *testing.T) {
|
||||
t.Fatalf("parseTargetString(%q) unexpected error: %v", tt.input, err)
|
||||
}
|
||||
|
||||
// Verify the format is valid for net.Dial by checking it can be split back
|
||||
// This doesn't actually dial, just validates the format
|
||||
_, _, err = net.SplitHostPort(targetAddr)
|
||||
if err != nil {
|
||||
t.Errorf("parseTargetString(%q) produced invalid net.Dial format %q: %v", tt.input, targetAddr, err)
|
||||
@@ -344,7 +213,18 @@ func TestParseTargetStringNetDialCompatibility(t *testing.T) {
|
||||
|
||||
// TestShouldFireRecovery is the regression guard for the broken trigger gate
|
||||
// that prevented data-plane recovery from ever firing under default settings
|
||||
// (fosrl/newt#284, #310, pangolin#1004).
|
||||
// (fosrl/newt#284, #310, pangolin#1004). The pre-fix condition was
|
||||
//
|
||||
// consecutiveFailures >= failureThreshold && currentInterval < maxInterval
|
||||
//
|
||||
// which became permanently false once pingInterval's default was bumped from
|
||||
// 3s to 15s in commit 8161fa6 — currentInterval starts at pingInterval=15s,
|
||||
// maxInterval stayed at 6s, so 15<6 is false and the recovery branch never
|
||||
// executed.
|
||||
//
|
||||
// The fix is to drop currentInterval from the trigger condition entirely;
|
||||
// backoff is a separate concern computed in the caller. The cases below
|
||||
// exercise the documented contract.
|
||||
func TestShouldFireRecovery(t *testing.T) {
|
||||
const threshold = 4
|
||||
cases := []struct {
|
||||
681
config.go
681
config.go
@@ -1,681 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/fosrl/newt/logger"
|
||||
newtpkg "github.com/fosrl/newt/newt"
|
||||
)
|
||||
|
||||
type stringSlice []string
|
||||
|
||||
func (s *stringSlice) String() string {
|
||||
return strings.Join(*s, ",")
|
||||
}
|
||||
|
||||
func (s *stringSlice) Set(value string) error {
|
||||
*s = append(*s, value)
|
||||
return nil
|
||||
}
|
||||
|
||||
// configSource records where a resolved setting came from, for --show-config.
|
||||
type configSource string
|
||||
|
||||
const (
|
||||
sourceDefault configSource = "default"
|
||||
sourceFile configSource = "file"
|
||||
sourceEnv configSource = "environment"
|
||||
sourceCLI configSource = "cli"
|
||||
)
|
||||
|
||||
// fileSettings mirrors the on-disk JSON config file schema. Fields are
|
||||
// pointers (or, for slices, nil-vs-non-empty) so that "absent from the file"
|
||||
// can be distinguished from an explicit zero value.
|
||||
type fileSettings struct {
|
||||
Endpoint *string `json:"endpoint"`
|
||||
ID *string `json:"id"`
|
||||
Secret *string `json:"secret"`
|
||||
ProvisioningKey *string `json:"provisioningKey"`
|
||||
Name *string `json:"name"`
|
||||
|
||||
DNS *string `json:"dns"`
|
||||
LogLevel *string `json:"logLevel"`
|
||||
UpdownScript *string `json:"updownScript"`
|
||||
InterfaceName *string `json:"interface"`
|
||||
MTU *int `json:"mtu"`
|
||||
Port *int `json:"port"`
|
||||
|
||||
UseNativeInterface *bool `json:"native"`
|
||||
UseNativeMainInterface *bool `json:"nativeMain"`
|
||||
NativeMainInterfaceName *string `json:"interfaceMain"`
|
||||
NoCloud *bool `json:"noCloud"`
|
||||
PreferEndpoint *string `json:"preferEndpoint"`
|
||||
|
||||
PingInterval *string `json:"pingInterval"`
|
||||
PingTimeout *string `json:"pingTimeout"`
|
||||
UDPProxyIdleTimeout *string `json:"udpProxyIdleTimeout"`
|
||||
|
||||
DisableClients *bool `json:"disableClients"`
|
||||
DisableSSH *bool `json:"disableSsh"`
|
||||
EnforceHealthcheckCert *bool `json:"enforceHcCert"`
|
||||
HealthFile *string `json:"healthFile"`
|
||||
BlueprintFile *string `json:"blueprintFile"`
|
||||
ProvisioningBlueprintFile *string `json:"provisioningBlueprintFile"`
|
||||
|
||||
DockerSocket *string `json:"dockerSocket"`
|
||||
DockerEnforceNetworkValidation *bool `json:"dockerEnforceNetworkValidation"`
|
||||
|
||||
AuthDaemonKey *string `json:"adPreSharedKey"`
|
||||
AuthDaemonPrincipalsFile *string `json:"adPrincipalsFile"`
|
||||
AuthDaemonCACertPath *string `json:"adCaCertPath"`
|
||||
AuthDaemonGenerateRandomPassword *bool `json:"adGenerateRandomPassword"`
|
||||
|
||||
TLSClientCert *string `json:"tlsClientCertFile"`
|
||||
TLSClientKey *string `json:"tlsClientKey"`
|
||||
TLSClientCAs []string `json:"tlsClientCa"`
|
||||
TLSPrivateKey *string `json:"tlsClientCert"` // legacy PKCS12 path; matches the key already written by the credential-save path
|
||||
|
||||
MetricsEnabled *bool `json:"metrics"`
|
||||
OTLPEnabled *bool `json:"otlp"`
|
||||
AdminAddr *string `json:"metricsAdminAddr"`
|
||||
Region *string `json:"region"`
|
||||
MetricsAsyncBytes *bool `json:"metricsAsyncBytes"`
|
||||
PprofEnabled *bool `json:"pprof"`
|
||||
}
|
||||
|
||||
// resolveConfigFilePath determines the settings/credentials file path using
|
||||
// the same precedence as every other setting: CLI > env > OS default.
|
||||
// It has to run before flag.Parse (which needs the file-resolved defaults),
|
||||
// so it scans os.Args directly instead of using the flag package.
|
||||
func resolveConfigFilePath(args []string) string {
|
||||
for i, a := range args {
|
||||
if a == "--config-file" || a == "-config-file" {
|
||||
if i+1 < len(args) {
|
||||
return args[i+1]
|
||||
}
|
||||
}
|
||||
if v, ok := strings.CutPrefix(a, "--config-file="); ok {
|
||||
return v
|
||||
}
|
||||
if v, ok := strings.CutPrefix(a, "-config-file="); ok {
|
||||
return v
|
||||
}
|
||||
}
|
||||
|
||||
if v := os.Getenv("CONFIG_FILE"); v != "" {
|
||||
return v
|
||||
}
|
||||
|
||||
var configDir string
|
||||
switch runtime.GOOS {
|
||||
case "darwin":
|
||||
configDir = filepath.Join(os.Getenv("HOME"), "Library", "Application Support", "newt-client")
|
||||
case "windows":
|
||||
configDir = filepath.Join(os.Getenv("PROGRAMDATA"), "newt", "newt-client")
|
||||
default: // linux and others
|
||||
configDir = filepath.Join(os.Getenv("HOME"), ".config", "newt-client")
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(configDir, 0755); err != nil {
|
||||
fmt.Printf("Warning: Failed to create config directory: %v\n", err)
|
||||
}
|
||||
|
||||
return filepath.Join(configDir, "config.json")
|
||||
}
|
||||
|
||||
// loadFileSettings reads and parses the config file. A missing or empty file
|
||||
// is not an error (returns nil, nil) since the file may not exist yet.
|
||||
func loadFileSettings(path string) (*fileSettings, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if len(strings.TrimSpace(string(data))) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var fs fileSettings
|
||||
if err := json.Unmarshal(data, &fs); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse config file %s: %w", path, err)
|
||||
}
|
||||
return &fs, nil
|
||||
}
|
||||
|
||||
func applyStr(dst *string, v *string, key string, sources map[string]string, src configSource) {
|
||||
if v != nil {
|
||||
*dst = *v
|
||||
sources[key] = string(src)
|
||||
}
|
||||
}
|
||||
|
||||
func applyBool(dst *bool, v *bool, key string, sources map[string]string, src configSource) {
|
||||
if v != nil {
|
||||
*dst = *v
|
||||
sources[key] = string(src)
|
||||
}
|
||||
}
|
||||
|
||||
func applyEnvStr(dst *string, envName, key string, sources map[string]string) {
|
||||
if v := os.Getenv(envName); v != "" {
|
||||
*dst = v
|
||||
sources[key] = string(sourceEnv)
|
||||
}
|
||||
}
|
||||
|
||||
func applyEnvBool(dst *bool, envName, key string, sources map[string]string) {
|
||||
if v := os.Getenv(envName); v != "" {
|
||||
*dst = v == "true"
|
||||
sources[key] = string(sourceEnv)
|
||||
}
|
||||
}
|
||||
|
||||
// validateTLSConfig validates that TLS config fields are consistent and that
|
||||
// referenced files exist.
|
||||
func validateTLSConfig(cfg newtpkg.Config) error {
|
||||
pkcs12Specified := cfg.TLSPrivateKey != ""
|
||||
separateFilesSpecified := cfg.TLSClientCert != "" || cfg.TLSClientKey != "" || len(cfg.TLSClientCAs) > 0
|
||||
|
||||
if pkcs12Specified && separateFilesSpecified {
|
||||
return fmt.Errorf("cannot use both PKCS12 format (--tls-client-cert) and separate certificate files (--tls-client-cert-file, --tls-client-key, --tls-client-ca)")
|
||||
}
|
||||
|
||||
if (cfg.TLSClientCert != "" && cfg.TLSClientKey == "") || (cfg.TLSClientCert == "" && cfg.TLSClientKey != "") {
|
||||
return fmt.Errorf("both --tls-client-cert-file and --tls-client-key must be specified together")
|
||||
}
|
||||
|
||||
if cfg.TLSClientCert != "" {
|
||||
if _, err := os.Stat(cfg.TLSClientCert); os.IsNotExist(err) {
|
||||
return fmt.Errorf("client certificate file does not exist: %s", cfg.TLSClientCert)
|
||||
}
|
||||
}
|
||||
|
||||
if cfg.TLSClientKey != "" {
|
||||
if _, err := os.Stat(cfg.TLSClientKey); os.IsNotExist(err) {
|
||||
return fmt.Errorf("client key file does not exist: %s", cfg.TLSClientKey)
|
||||
}
|
||||
}
|
||||
|
||||
for _, caFile := range cfg.TLSClientCAs {
|
||||
if _, err := os.Stat(caFile); os.IsNotExist(err) {
|
||||
return fmt.Errorf("CA certificate file does not exist: %s", caFile)
|
||||
}
|
||||
}
|
||||
|
||||
if cfg.TLSPrivateKey != "" {
|
||||
if _, err := os.Stat(cfg.TLSPrivateKey); os.IsNotExist(err) {
|
||||
return fmt.Errorf("PKCS12 certificate file does not exist: %s", cfg.TLSPrivateKey)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// parseDurationEnvOrFlag parses s as a duration, using defaultVal on failure.
|
||||
func parseDurationEnvOrFlag(s string, defaultVal time.Duration, label string) time.Duration {
|
||||
if s == "" {
|
||||
return defaultVal
|
||||
}
|
||||
d, err := time.ParseDuration(s)
|
||||
if err != nil || d <= 0 {
|
||||
fmt.Printf("Invalid %s value: %s, using default %v\n", label, s, defaultVal)
|
||||
return defaultVal
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
// loadNewtConfig resolves configuration with priority cli > env > file >
|
||||
// default, then returns a populated newtpkg.Config. This function calls
|
||||
// flag.Parse internally and will exit the process if --version or
|
||||
// --show-config is passed.
|
||||
func loadNewtConfig() newtpkg.Config {
|
||||
sources := make(map[string]string)
|
||||
|
||||
configPath := resolveConfigFilePath(os.Args[1:])
|
||||
fileCfg, err := loadFileSettings(configPath)
|
||||
if err != nil {
|
||||
logger.Fatal("Failed to load config file: %v", err)
|
||||
}
|
||||
|
||||
// ---- defaults ----
|
||||
cfg := newtpkg.Config{
|
||||
Version: newtVersion,
|
||||
Platform: newtPlatform,
|
||||
|
||||
DNS: "9.9.9.9",
|
||||
LogLevel: "INFO",
|
||||
InterfaceName: "newt",
|
||||
NativeMainInterfaceName: "newt",
|
||||
AuthDaemonPrincipalsFile: "/var/run/auth-daemon/principals",
|
||||
AuthDaemonCACertPath: "/etc/ssh/ca.pem",
|
||||
AdminAddr: "127.0.0.1:2112",
|
||||
}
|
||||
|
||||
mtuStr := "1280"
|
||||
portStr := ""
|
||||
pingIntervalStr := "15s"
|
||||
pingTimeoutStr := "7s"
|
||||
udpProxyIdleTimeoutStr := "90s"
|
||||
dockerEnforceStr := "false"
|
||||
|
||||
// ---- layer 1: config file ----
|
||||
if fileCfg != nil {
|
||||
applyStr(&cfg.Endpoint, fileCfg.Endpoint, "endpoint", sources, sourceFile)
|
||||
applyStr(&cfg.ID, fileCfg.ID, "id", sources, sourceFile)
|
||||
applyStr(&cfg.Secret, fileCfg.Secret, "secret", sources, sourceFile)
|
||||
applyStr(&cfg.ProvisioningKey, fileCfg.ProvisioningKey, "provisioning-key", sources, sourceFile)
|
||||
applyStr(&cfg.NewtName, fileCfg.Name, "name", sources, sourceFile)
|
||||
|
||||
applyStr(&cfg.DNS, fileCfg.DNS, "dns", sources, sourceFile)
|
||||
applyStr(&cfg.LogLevel, fileCfg.LogLevel, "log-level", sources, sourceFile)
|
||||
applyStr(&cfg.UpdownScript, fileCfg.UpdownScript, "updown", sources, sourceFile)
|
||||
applyStr(&cfg.InterfaceName, fileCfg.InterfaceName, "interface", sources, sourceFile)
|
||||
if fileCfg.MTU != nil {
|
||||
mtuStr = strconv.Itoa(*fileCfg.MTU)
|
||||
sources["mtu"] = string(sourceFile)
|
||||
}
|
||||
if fileCfg.Port != nil {
|
||||
portStr = strconv.Itoa(*fileCfg.Port)
|
||||
sources["port"] = string(sourceFile)
|
||||
}
|
||||
|
||||
applyBool(&cfg.UseNativeInterface, fileCfg.UseNativeInterface, "native", sources, sourceFile)
|
||||
applyBool(&cfg.UseNativeMainInterface, fileCfg.UseNativeMainInterface, "native-main", sources, sourceFile)
|
||||
applyStr(&cfg.NativeMainInterfaceName, fileCfg.NativeMainInterfaceName, "interface-main", sources, sourceFile)
|
||||
applyBool(&cfg.NoCloud, fileCfg.NoCloud, "no-cloud", sources, sourceFile)
|
||||
applyStr(&cfg.PreferEndpoint, fileCfg.PreferEndpoint, "prefer-endpoint", sources, sourceFile)
|
||||
|
||||
applyStr(&pingIntervalStr, fileCfg.PingInterval, "ping-interval", sources, sourceFile)
|
||||
applyStr(&pingTimeoutStr, fileCfg.PingTimeout, "ping-timeout", sources, sourceFile)
|
||||
applyStr(&udpProxyIdleTimeoutStr, fileCfg.UDPProxyIdleTimeout, "udp-proxy-idle-timeout", sources, sourceFile)
|
||||
|
||||
applyBool(&cfg.DisableClients, fileCfg.DisableClients, "disable-clients", sources, sourceFile)
|
||||
applyBool(&cfg.DisableSSH, fileCfg.DisableSSH, "disable-ssh", sources, sourceFile)
|
||||
applyBool(&cfg.EnforceHealthcheckCert, fileCfg.EnforceHealthcheckCert, "enforce-hc-cert", sources, sourceFile)
|
||||
applyStr(&cfg.HealthFile, fileCfg.HealthFile, "health-file", sources, sourceFile)
|
||||
applyStr(&cfg.BlueprintFile, fileCfg.BlueprintFile, "blueprint-file", sources, sourceFile)
|
||||
applyStr(&cfg.ProvisioningBlueprintFile, fileCfg.ProvisioningBlueprintFile, "provisioning-blueprint-file", sources, sourceFile)
|
||||
|
||||
applyStr(&cfg.DockerSocket, fileCfg.DockerSocket, "docker-socket", sources, sourceFile)
|
||||
if fileCfg.DockerEnforceNetworkValidation != nil {
|
||||
dockerEnforceStr = strconv.FormatBool(*fileCfg.DockerEnforceNetworkValidation)
|
||||
sources["docker-enforce-network-validation"] = string(sourceFile)
|
||||
}
|
||||
|
||||
applyStr(&cfg.AuthDaemonKey, fileCfg.AuthDaemonKey, "ad-pre-shared-key", sources, sourceFile)
|
||||
applyStr(&cfg.AuthDaemonPrincipalsFile, fileCfg.AuthDaemonPrincipalsFile, "ad-principals-file", sources, sourceFile)
|
||||
applyStr(&cfg.AuthDaemonCACertPath, fileCfg.AuthDaemonCACertPath, "ad-ca-cert-path", sources, sourceFile)
|
||||
applyBool(&cfg.AuthDaemonGenerateRandomPassword, fileCfg.AuthDaemonGenerateRandomPassword, "ad-generate-random-password", sources, sourceFile)
|
||||
|
||||
applyStr(&cfg.TLSClientCert, fileCfg.TLSClientCert, "tls-client-cert-file", sources, sourceFile)
|
||||
applyStr(&cfg.TLSClientKey, fileCfg.TLSClientKey, "tls-client-key", sources, sourceFile)
|
||||
if len(fileCfg.TLSClientCAs) > 0 {
|
||||
cfg.TLSClientCAs = append(cfg.TLSClientCAs, fileCfg.TLSClientCAs...)
|
||||
sources["tls-client-ca"] = string(sourceFile)
|
||||
}
|
||||
applyStr(&cfg.TLSPrivateKey, fileCfg.TLSPrivateKey, "tls-client-cert", sources, sourceFile)
|
||||
|
||||
applyBool(&cfg.MetricsEnabled, fileCfg.MetricsEnabled, "metrics", sources, sourceFile)
|
||||
applyBool(&cfg.OTLPEnabled, fileCfg.OTLPEnabled, "otlp", sources, sourceFile)
|
||||
applyStr(&cfg.AdminAddr, fileCfg.AdminAddr, "metrics-admin-addr", sources, sourceFile)
|
||||
applyStr(&cfg.Region, fileCfg.Region, "region", sources, sourceFile)
|
||||
applyBool(&cfg.MetricsAsyncBytes, fileCfg.MetricsAsyncBytes, "metrics-async-bytes", sources, sourceFile)
|
||||
applyBool(&cfg.PprofEnabled, fileCfg.PprofEnabled, "pprof", sources, sourceFile)
|
||||
}
|
||||
|
||||
// ---- layer 2: environment variables ----
|
||||
applyEnvStr(&cfg.Endpoint, "PANGOLIN_ENDPOINT", "endpoint", sources)
|
||||
applyEnvStr(&cfg.ID, "NEWT_ID", "id", sources)
|
||||
applyEnvStr(&cfg.Secret, "NEWT_SECRET", "secret", sources)
|
||||
applyEnvStr(&cfg.ProvisioningKey, "NEWT_PROVISIONING_KEY", "provisioning-key", sources)
|
||||
applyEnvStr(&cfg.NewtName, "NEWT_NAME", "name", sources)
|
||||
|
||||
applyEnvStr(&cfg.DNS, "DNS", "dns", sources)
|
||||
applyEnvStr(&cfg.LogLevel, "LOG_LEVEL", "log-level", sources)
|
||||
applyEnvStr(&cfg.UpdownScript, "UPDOWN_SCRIPT", "updown", sources)
|
||||
applyEnvStr(&cfg.InterfaceName, "INTERFACE", "interface", sources)
|
||||
applyEnvStr(&mtuStr, "MTU", "mtu", sources)
|
||||
applyEnvStr(&portStr, "PORT", "port", sources)
|
||||
|
||||
applyEnvBool(&cfg.UseNativeInterface, "USE_NATIVE_INTERFACE", "native", sources)
|
||||
applyEnvBool(&cfg.UseNativeMainInterface, "USE_NATIVE_MAIN_INTERFACE", "native-main", sources)
|
||||
applyEnvStr(&cfg.NativeMainInterfaceName, "INTERFACE_MAIN", "interface-main", sources)
|
||||
applyEnvBool(&cfg.NoCloud, "NO_CLOUD", "no-cloud", sources)
|
||||
|
||||
applyEnvStr(&pingIntervalStr, "PING_INTERVAL", "ping-interval", sources)
|
||||
applyEnvStr(&pingTimeoutStr, "PING_TIMEOUT", "ping-timeout", sources)
|
||||
applyEnvStr(&udpProxyIdleTimeoutStr, "NEWT_UDP_PROXY_IDLE_TIMEOUT", "udp-proxy-idle-timeout", sources)
|
||||
|
||||
applyEnvBool(&cfg.DisableClients, "DISABLE_CLIENTS", "disable-clients", sources)
|
||||
applyEnvBool(&cfg.DisableSSH, "DISABLE_SSH", "disable-ssh", sources)
|
||||
applyEnvBool(&cfg.EnforceHealthcheckCert, "ENFORCE_HC_CERT", "enforce-hc-cert", sources)
|
||||
applyEnvStr(&cfg.HealthFile, "HEALTH_FILE", "health-file", sources)
|
||||
applyEnvStr(&cfg.BlueprintFile, "BLUEPRINT_FILE", "blueprint-file", sources)
|
||||
applyEnvStr(&cfg.ProvisioningBlueprintFile, "PROVISIONING_BLUEPRINT_FILE", "provisioning-blueprint-file", sources)
|
||||
|
||||
applyEnvStr(&cfg.DockerSocket, "DOCKER_SOCKET", "docker-socket", sources)
|
||||
applyEnvStr(&dockerEnforceStr, "DOCKER_ENFORCE_NETWORK_VALIDATION", "docker-enforce-network-validation", sources)
|
||||
|
||||
applyEnvStr(&cfg.AuthDaemonKey, "AD_KEY", "ad-pre-shared-key", sources)
|
||||
applyEnvStr(&cfg.AuthDaemonPrincipalsFile, "AD_PRINCIPALS_FILE", "ad-principals-file", sources)
|
||||
applyEnvStr(&cfg.AuthDaemonCACertPath, "AD_CA_CERT_PATH", "ad-ca-cert-path", sources)
|
||||
if v, err := strconv.ParseBool(os.Getenv("AD_GENERATE_RANDOM_PASSWORD")); err == nil {
|
||||
cfg.AuthDaemonGenerateRandomPassword = v
|
||||
sources["ad-generate-random-password"] = string(sourceEnv)
|
||||
}
|
||||
|
||||
applyEnvStr(&cfg.TLSClientCert, "TLS_CLIENT_CERT", "tls-client-cert-file", sources)
|
||||
applyEnvStr(&cfg.TLSClientKey, "TLS_CLIENT_KEY", "tls-client-key", sources)
|
||||
if tlsClientCAsEnv := os.Getenv("TLS_CLIENT_CAS"); tlsClientCAsEnv != "" {
|
||||
for _, ca := range strings.Split(tlsClientCAsEnv, ",") {
|
||||
cfg.TLSClientCAs = append(cfg.TLSClientCAs, strings.TrimSpace(ca))
|
||||
}
|
||||
sources["tls-client-ca"] = string(sourceEnv)
|
||||
}
|
||||
applyEnvStr(&cfg.TLSPrivateKey, "TLS_CLIENT_CERT_PKCS12", "tls-client-cert", sources)
|
||||
|
||||
// Legacy PKCS12 backward-compat: fall back to the (already layered)
|
||||
// separate-cert-file value for PKCS12 when the newer fields are unset.
|
||||
if cfg.TLSPrivateKey == "" && cfg.TLSClientKey == "" && len(cfg.TLSClientCAs) == 0 && cfg.TLSClientCert != "" {
|
||||
cfg.TLSPrivateKey = cfg.TLSClientCert
|
||||
sources["tls-client-cert"] = sources["tls-client-cert-file"]
|
||||
}
|
||||
|
||||
if metricsEnabledEnv := os.Getenv("NEWT_METRICS_PROMETHEUS_ENABLED"); metricsEnabledEnv != "" {
|
||||
if v, err := strconv.ParseBool(metricsEnabledEnv); err == nil {
|
||||
cfg.MetricsEnabled = v
|
||||
} else {
|
||||
cfg.MetricsEnabled = true
|
||||
}
|
||||
sources["metrics"] = string(sourceEnv)
|
||||
}
|
||||
applyEnvBool(&cfg.OTLPEnabled, "NEWT_METRICS_OTLP_ENABLED", "otlp", sources)
|
||||
applyEnvStr(&cfg.AdminAddr, "NEWT_ADMIN_ADDR", "metrics-admin-addr", sources)
|
||||
applyEnvStr(&cfg.Region, "NEWT_REGION", "region", sources)
|
||||
applyEnvBool(&cfg.MetricsAsyncBytes, "NEWT_METRICS_ASYNC_BYTES", "metrics-async-bytes", sources)
|
||||
applyEnvBool(&cfg.PprofEnabled, "NEWT_PPROF_ENABLED", "pprof", sources)
|
||||
|
||||
// ---- layer 3: CLI flags (always registered; default = file/env-resolved value) ----
|
||||
origEndpoint, origID, origSecret := cfg.Endpoint, cfg.ID, cfg.Secret
|
||||
origMTU, origDNS, origLogLevel := mtuStr, cfg.DNS, cfg.LogLevel
|
||||
origUpdown, origInterface, origPort := cfg.UpdownScript, cfg.InterfaceName, portStr
|
||||
origNative, origNativeMain, origInterfaceMain := cfg.UseNativeInterface, cfg.UseNativeMainInterface, cfg.NativeMainInterfaceName
|
||||
origDisableClients, origDisableSSH, origEnforceHC := cfg.DisableClients, cfg.DisableSSH, cfg.EnforceHealthcheckCert
|
||||
origDockerSocket, origPingInterval, origPingTimeout := cfg.DockerSocket, pingIntervalStr, pingTimeoutStr
|
||||
origUDPIdle, origProvisioningKey, origName := udpProxyIdleTimeoutStr, cfg.ProvisioningKey, cfg.NewtName
|
||||
origTLSCert, origTLSKey, origDockerEnforce := cfg.TLSClientCert, cfg.TLSClientKey, dockerEnforceStr
|
||||
origHealthFile, origBlueprintFile, origProvBlueprintFile := cfg.HealthFile, cfg.BlueprintFile, cfg.ProvisioningBlueprintFile
|
||||
origNoCloud, origTLSPrivateKey := cfg.NoCloud, cfg.TLSPrivateKey
|
||||
origMetrics, origOTLP, origAdminAddr := cfg.MetricsEnabled, cfg.OTLPEnabled, cfg.AdminAddr
|
||||
origMetricsAsync, origPprof, origRegion := cfg.MetricsAsyncBytes, cfg.PprofEnabled, cfg.Region
|
||||
origADKey, origADPrincipals, origADCACert := cfg.AuthDaemonKey, cfg.AuthDaemonPrincipalsFile, cfg.AuthDaemonCACertPath
|
||||
origADRandomPass := cfg.AuthDaemonGenerateRandomPassword
|
||||
|
||||
flag.StringVar(&cfg.Endpoint, "endpoint", cfg.Endpoint, "Endpoint of your pangolin server")
|
||||
flag.StringVar(&cfg.ID, "id", cfg.ID, "Newt ID")
|
||||
flag.StringVar(&cfg.Secret, "secret", cfg.Secret, "Newt secret")
|
||||
flag.StringVar(&mtuStr, "mtu", mtuStr, "MTU to use")
|
||||
flag.StringVar(&cfg.DNS, "dns", cfg.DNS, "DNS server to use")
|
||||
flag.StringVar(&cfg.LogLevel, "log-level", cfg.LogLevel, "Log level (DEBUG, INFO, WARN, ERROR, FATAL)")
|
||||
flag.StringVar(&cfg.UpdownScript, "updown", cfg.UpdownScript, "Path to updown script to be called when targets are added or removed")
|
||||
flag.StringVar(&cfg.InterfaceName, "interface", cfg.InterfaceName, "Name of the WireGuard interface")
|
||||
flag.StringVar(&portStr, "port", portStr, "Port for client WireGuard interface")
|
||||
flag.BoolVar(&cfg.UseNativeInterface, "native", cfg.UseNativeInterface, "Use native WireGuard interface for client tunnels")
|
||||
flag.BoolVar(&cfg.UseNativeMainInterface, "native-main", cfg.UseNativeMainInterface, "Use native WireGuard interface for the main tunnel (instead of netstack)")
|
||||
// making this the same as above should prevent them from running together
|
||||
flag.StringVar(&cfg.NativeMainInterfaceName, "interface-main", cfg.NativeMainInterfaceName, "Name of the native main tunnel WireGuard interface (used with --native-main)")
|
||||
flag.BoolVar(&cfg.DisableClients, "disable-clients", cfg.DisableClients, "Disable clients on the WireGuard interface")
|
||||
flag.BoolVar(&cfg.DisableSSH, "disable-ssh", cfg.DisableSSH, "Disable SSH auth daemon and native SSH mode (remote auth daemon still works)")
|
||||
flag.BoolVar(&cfg.EnforceHealthcheckCert, "enforce-hc-cert", cfg.EnforceHealthcheckCert, "Enforce certificate validation for health checks (default: false, accepts any cert)")
|
||||
flag.StringVar(&cfg.DockerSocket, "docker-socket", cfg.DockerSocket, "Path or address to Docker socket (typically unix:///var/run/docker.sock)")
|
||||
flag.StringVar(&pingIntervalStr, "ping-interval", pingIntervalStr, "Interval for pinging the server (default 15s)")
|
||||
flag.StringVar(&pingTimeoutStr, "ping-timeout", pingTimeoutStr, "Timeout for each ping (default 7s)")
|
||||
flag.StringVar(&udpProxyIdleTimeoutStr, "udp-proxy-idle-timeout", udpProxyIdleTimeoutStr, "Idle timeout for UDP proxied client flows before cleanup")
|
||||
flag.StringVar(&cfg.PreferEndpoint, "prefer-endpoint", cfg.PreferEndpoint, "Prefer this endpoint for the connection (if set, will override the endpoint from the server)")
|
||||
flag.StringVar(&cfg.ProvisioningKey, "provisioning-key", cfg.ProvisioningKey, "One-time provisioning key used to obtain a newt ID and secret from the server")
|
||||
flag.StringVar(&cfg.NewtName, "name", cfg.NewtName, "Name for the site created during provisioning (supports {{env.VAR}} interpolation)")
|
||||
flag.StringVar(&cfg.ConfigFile, "config-file", configPath, "Path to config file (overrides CONFIG_FILE env var and default location)")
|
||||
flag.StringVar(&cfg.TLSClientCert, "tls-client-cert-file", cfg.TLSClientCert, "Path to client certificate file (PEM/DER format)")
|
||||
flag.StringVar(&cfg.TLSClientKey, "tls-client-key", cfg.TLSClientKey, "Path to client private key file (PEM/DER format)")
|
||||
// Backward-compat dummy flag (auth daemon is always enabled now)
|
||||
flag.Bool("auth-daemon", false, "Enable auth daemon mode (deprecated, always enabled)")
|
||||
|
||||
var tlsClientCAsFlag stringSlice
|
||||
flag.Var(&tlsClientCAsFlag, "tls-client-ca", "Path to CA certificate file for validating remote certificates (can be specified multiple times)")
|
||||
|
||||
flag.StringVar(&cfg.TLSPrivateKey, "tls-client-cert", cfg.TLSPrivateKey, "Path to client certificate (PKCS12 format) - DEPRECATED: use --tls-client-cert-file and --tls-client-key instead")
|
||||
flag.StringVar(&dockerEnforceStr, "docker-enforce-network-validation", dockerEnforceStr, "Enforce validation of container on newt network (true or false)")
|
||||
flag.StringVar(&cfg.HealthFile, "health-file", cfg.HealthFile, "Path to health file (if unset, health file won't be written)")
|
||||
flag.StringVar(&cfg.BlueprintFile, "blueprint-file", cfg.BlueprintFile, "Path to blueprint file (if unset, no blueprint will be applied)")
|
||||
flag.StringVar(&cfg.ProvisioningBlueprintFile, "provisioning-blueprint-file", cfg.ProvisioningBlueprintFile, "Path to blueprint file applied once after a provisioning credential exchange (if unset, no provisioning blueprint will be applied)")
|
||||
flag.BoolVar(&cfg.NoCloud, "no-cloud", cfg.NoCloud, "Disable cloud failover")
|
||||
flag.BoolVar(&cfg.MetricsEnabled, "metrics", cfg.MetricsEnabled, "Enable Prometheus metrics exporter")
|
||||
flag.BoolVar(&cfg.OTLPEnabled, "otlp", cfg.OTLPEnabled, "Enable OTLP exporters (metrics/traces) to OTEL_EXPORTER_OTLP_ENDPOINT")
|
||||
flag.StringVar(&cfg.AdminAddr, "metrics-admin-addr", cfg.AdminAddr, "Admin/metrics bind address")
|
||||
flag.BoolVar(&cfg.MetricsAsyncBytes, "metrics-async-bytes", cfg.MetricsAsyncBytes, "Enable async bytes counting (background flush; lower hot path overhead)")
|
||||
flag.BoolVar(&cfg.PprofEnabled, "pprof", cfg.PprofEnabled, "Enable pprof debug endpoints on admin server")
|
||||
flag.StringVar(&cfg.Region, "region", cfg.Region, "Optional region resource attribute (also NEWT_REGION)")
|
||||
flag.StringVar(&cfg.AuthDaemonKey, "ad-pre-shared-key", cfg.AuthDaemonKey, "Pre-shared key for auth daemon authentication")
|
||||
flag.StringVar(&cfg.AuthDaemonPrincipalsFile, "ad-principals-file", cfg.AuthDaemonPrincipalsFile, "Path to the principals file for auth daemon")
|
||||
flag.StringVar(&cfg.AuthDaemonCACertPath, "ad-ca-cert-path", cfg.AuthDaemonCACertPath, "Path to the CA certificate file for auth daemon")
|
||||
flag.BoolVar(&cfg.AuthDaemonGenerateRandomPassword, "ad-generate-random-password", cfg.AuthDaemonGenerateRandomPassword, "Generate a random password for authenticated users")
|
||||
|
||||
version := flag.Bool("version", false, "Print the version")
|
||||
showConfig := flag.Bool("show-config", false, "Show configuration values and their sources, then exit")
|
||||
|
||||
flag.Parse()
|
||||
|
||||
// ---- post-parse processing ----
|
||||
|
||||
// Merge CLI CA files onto whatever file/env already contributed.
|
||||
if len(tlsClientCAsFlag) > 0 {
|
||||
cfg.TLSClientCAs = append(cfg.TLSClientCAs, tlsClientCAsFlag...)
|
||||
sources["tls-client-ca"] = string(sourceCLI)
|
||||
}
|
||||
|
||||
markCLI := func(key string, changed bool) {
|
||||
if changed {
|
||||
sources[key] = string(sourceCLI)
|
||||
}
|
||||
}
|
||||
markCLI("endpoint", cfg.Endpoint != origEndpoint)
|
||||
markCLI("id", cfg.ID != origID)
|
||||
markCLI("secret", cfg.Secret != origSecret)
|
||||
markCLI("mtu", mtuStr != origMTU)
|
||||
markCLI("dns", cfg.DNS != origDNS)
|
||||
markCLI("log-level", cfg.LogLevel != origLogLevel)
|
||||
markCLI("updown", cfg.UpdownScript != origUpdown)
|
||||
markCLI("interface", cfg.InterfaceName != origInterface)
|
||||
markCLI("port", portStr != origPort)
|
||||
markCLI("native", cfg.UseNativeInterface != origNative)
|
||||
markCLI("native-main", cfg.UseNativeMainInterface != origNativeMain)
|
||||
markCLI("interface-main", cfg.NativeMainInterfaceName != origInterfaceMain)
|
||||
markCLI("disable-clients", cfg.DisableClients != origDisableClients)
|
||||
markCLI("disable-ssh", cfg.DisableSSH != origDisableSSH)
|
||||
markCLI("enforce-hc-cert", cfg.EnforceHealthcheckCert != origEnforceHC)
|
||||
markCLI("docker-socket", cfg.DockerSocket != origDockerSocket)
|
||||
markCLI("ping-interval", pingIntervalStr != origPingInterval)
|
||||
markCLI("ping-timeout", pingTimeoutStr != origPingTimeout)
|
||||
markCLI("udp-proxy-idle-timeout", udpProxyIdleTimeoutStr != origUDPIdle)
|
||||
markCLI("provisioning-key", cfg.ProvisioningKey != origProvisioningKey)
|
||||
markCLI("name", cfg.NewtName != origName)
|
||||
markCLI("tls-client-cert-file", cfg.TLSClientCert != origTLSCert)
|
||||
markCLI("tls-client-key", cfg.TLSClientKey != origTLSKey)
|
||||
markCLI("tls-client-cert", cfg.TLSPrivateKey != origTLSPrivateKey)
|
||||
markCLI("docker-enforce-network-validation", dockerEnforceStr != origDockerEnforce)
|
||||
markCLI("health-file", cfg.HealthFile != origHealthFile)
|
||||
markCLI("blueprint-file", cfg.BlueprintFile != origBlueprintFile)
|
||||
markCLI("provisioning-blueprint-file", cfg.ProvisioningBlueprintFile != origProvBlueprintFile)
|
||||
markCLI("no-cloud", cfg.NoCloud != origNoCloud)
|
||||
markCLI("metrics", cfg.MetricsEnabled != origMetrics)
|
||||
markCLI("otlp", cfg.OTLPEnabled != origOTLP)
|
||||
markCLI("metrics-admin-addr", cfg.AdminAddr != origAdminAddr)
|
||||
markCLI("metrics-async-bytes", cfg.MetricsAsyncBytes != origMetricsAsync)
|
||||
markCLI("pprof", cfg.PprofEnabled != origPprof)
|
||||
markCLI("region", cfg.Region != origRegion)
|
||||
markCLI("ad-pre-shared-key", cfg.AuthDaemonKey != origADKey)
|
||||
markCLI("ad-principals-file", cfg.AuthDaemonPrincipalsFile != origADPrincipals)
|
||||
markCLI("ad-ca-cert-path", cfg.AuthDaemonCACertPath != origADCACert)
|
||||
markCLI("ad-generate-random-password", cfg.AuthDaemonGenerateRandomPassword != origADRandomPass)
|
||||
if cfg.ConfigFile != configPath {
|
||||
sources["config-file"] = string(sourceCLI)
|
||||
}
|
||||
|
||||
// Version check (exits process)
|
||||
if *version {
|
||||
fmt.Println("Newt version " + newtVersion)
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
if *showConfig {
|
||||
printShowConfig(cfg, sources, configPath, mtuStr, portStr, pingIntervalStr, pingTimeoutStr, udpProxyIdleTimeoutStr, dockerEnforceStr)
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
logger.Info("Newt version %s", newtVersion)
|
||||
|
||||
// Parse port
|
||||
if portStr != "" {
|
||||
portInt, err := strconv.Atoi(portStr)
|
||||
if err != nil {
|
||||
logger.Warn("Failed to parse PORT, choosing a random port")
|
||||
} else {
|
||||
cfg.Port = uint16(portInt)
|
||||
}
|
||||
}
|
||||
|
||||
// Parse MTU
|
||||
if mtuStr == "" {
|
||||
mtuStr = "1280"
|
||||
}
|
||||
mtuInt, err := strconv.Atoi(mtuStr)
|
||||
if err != nil {
|
||||
logger.Fatal("Failed to parse MTU: %v", err)
|
||||
}
|
||||
cfg.MTU = mtuInt
|
||||
|
||||
// Parse docker network validation flag
|
||||
if v, err := strconv.ParseBool(dockerEnforceStr); err == nil {
|
||||
cfg.DockerEnforceNetworkValidation = v
|
||||
} else {
|
||||
logger.Info("Docker enforce network validation cannot be parsed. Defaulting to 'false'")
|
||||
cfg.DockerEnforceNetworkValidation = false
|
||||
}
|
||||
|
||||
// Parse durations (after flag.Parse so CLI flags take effect)
|
||||
cfg.PingInterval = parseDurationEnvOrFlag(pingIntervalStr, 15*time.Second, "PING_INTERVAL")
|
||||
cfg.PingTimeout = parseDurationEnvOrFlag(pingTimeoutStr, 7*time.Second, "PING_TIMEOUT")
|
||||
cfg.UDPProxyIdleTimeout = parseDurationEnvOrFlag(udpProxyIdleTimeoutStr, 90*time.Second, "NEWT_UDP_PROXY_IDLE_TIMEOUT")
|
||||
|
||||
return cfg
|
||||
}
|
||||
|
||||
// printShowConfig prints the resolved configuration and the source of each value
|
||||
func printShowConfig(cfg newtpkg.Config, sources map[string]string, configPath, mtuStr, portStr, pingIntervalStr, pingTimeoutStr, udpProxyIdleTimeoutStr, dockerEnforceStr string) {
|
||||
getSource := func(key string) string {
|
||||
if s, ok := sources[key]; ok && s != "" {
|
||||
return s
|
||||
}
|
||||
return string(sourceDefault)
|
||||
}
|
||||
mask := func(key, value string) string {
|
||||
if key == "secret" && value != "" {
|
||||
if len(value) > 8 {
|
||||
return value[:4] + "****" + value[len(value)-4:]
|
||||
}
|
||||
return "****"
|
||||
}
|
||||
if value == "" {
|
||||
return "(not set)"
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
fmt.Print("\n=== Newt Configuration ===\n\n")
|
||||
fmt.Printf("Config File: %s\n", configPath)
|
||||
if _, err := os.Stat(configPath); err == nil {
|
||||
fmt.Printf("Config File Status: exists\n")
|
||||
} else {
|
||||
fmt.Printf("Config File Status: not found\n")
|
||||
}
|
||||
|
||||
fmt.Println("\n--- Configuration Values ---")
|
||||
fmt.Print("(Format: Setting = Value [source])\n\n")
|
||||
|
||||
fmt.Println("Connection:")
|
||||
fmt.Printf(" endpoint = %s [%s]\n", mask("endpoint", cfg.Endpoint), getSource("endpoint"))
|
||||
fmt.Printf(" id = %s [%s]\n", mask("id", cfg.ID), getSource("id"))
|
||||
fmt.Printf(" secret = %s [%s]\n", mask("secret", cfg.Secret), getSource("secret"))
|
||||
fmt.Printf(" provisioning-key = %s [%s]\n", mask("provisioning-key", cfg.ProvisioningKey), getSource("provisioning-key"))
|
||||
fmt.Printf(" name = %s [%s]\n", mask("name", cfg.NewtName), getSource("name"))
|
||||
fmt.Printf(" prefer-endpoint = %s [%s]\n", mask("prefer-endpoint", cfg.PreferEndpoint), getSource("prefer-endpoint"))
|
||||
|
||||
fmt.Println("\nNetwork:")
|
||||
fmt.Printf(" mtu = %s [%s]\n", mtuStr, getSource("mtu"))
|
||||
fmt.Printf(" dns = %s [%s]\n", cfg.DNS, getSource("dns"))
|
||||
fmt.Printf(" interface = %s [%s]\n", cfg.InterfaceName, getSource("interface"))
|
||||
fmt.Printf(" port = %s [%s]\n", mask("port", portStr), getSource("port"))
|
||||
fmt.Printf(" native = %v [%s]\n", cfg.UseNativeInterface, getSource("native"))
|
||||
fmt.Printf(" native-main = %v [%s]\n", cfg.UseNativeMainInterface, getSource("native-main"))
|
||||
fmt.Printf(" interface-main = %s [%s]\n", cfg.NativeMainInterfaceName, getSource("interface-main"))
|
||||
fmt.Printf(" no-cloud = %v [%s]\n", cfg.NoCloud, getSource("no-cloud"))
|
||||
|
||||
fmt.Println("\nLogging:")
|
||||
fmt.Printf(" log-level = %s [%s]\n", cfg.LogLevel, getSource("log-level"))
|
||||
|
||||
fmt.Println("\nTiming:")
|
||||
fmt.Printf(" ping-interval = %s [%s]\n", pingIntervalStr, getSource("ping-interval"))
|
||||
fmt.Printf(" ping-timeout = %s [%s]\n", pingTimeoutStr, getSource("ping-timeout"))
|
||||
fmt.Printf(" udp-proxy-idle-timeout = %s [%s]\n", udpProxyIdleTimeoutStr, getSource("udp-proxy-idle-timeout"))
|
||||
|
||||
fmt.Println("\nFeatures:")
|
||||
fmt.Printf(" disable-clients = %v [%s]\n", cfg.DisableClients, getSource("disable-clients"))
|
||||
fmt.Printf(" disable-ssh = %v [%s]\n", cfg.DisableSSH, getSource("disable-ssh"))
|
||||
fmt.Printf(" enforce-hc-cert = %v [%s]\n", cfg.EnforceHealthcheckCert, getSource("enforce-hc-cert"))
|
||||
fmt.Printf(" health-file = %s [%s]\n", mask("health-file", cfg.HealthFile), getSource("health-file"))
|
||||
fmt.Printf(" blueprint-file = %s [%s]\n", mask("blueprint-file", cfg.BlueprintFile), getSource("blueprint-file"))
|
||||
fmt.Printf(" provisioning-blueprint-file = %s [%s]\n", mask("provisioning-blueprint-file", cfg.ProvisioningBlueprintFile), getSource("provisioning-blueprint-file"))
|
||||
fmt.Printf(" updown = %s [%s]\n", mask("updown", cfg.UpdownScript), getSource("updown"))
|
||||
|
||||
fmt.Println("\nDocker:")
|
||||
fmt.Printf(" docker-socket = %s [%s]\n", mask("docker-socket", cfg.DockerSocket), getSource("docker-socket"))
|
||||
fmt.Printf(" docker-enforce-network-validation = %s [%s]\n", dockerEnforceStr, getSource("docker-enforce-network-validation"))
|
||||
|
||||
fmt.Println("\nAuth Daemon:")
|
||||
fmt.Printf(" ad-pre-shared-key = %s [%s]\n", mask("ad-pre-shared-key", cfg.AuthDaemonKey), getSource("ad-pre-shared-key"))
|
||||
fmt.Printf(" ad-principals-file = %s [%s]\n", cfg.AuthDaemonPrincipalsFile, getSource("ad-principals-file"))
|
||||
fmt.Printf(" ad-ca-cert-path = %s [%s]\n", cfg.AuthDaemonCACertPath, getSource("ad-ca-cert-path"))
|
||||
fmt.Printf(" ad-generate-random-password = %v [%s]\n", cfg.AuthDaemonGenerateRandomPassword, getSource("ad-generate-random-password"))
|
||||
|
||||
fmt.Println("\nTLS:")
|
||||
fmt.Printf(" tls-client-cert-file = %s [%s]\n", mask("tls-client-cert-file", cfg.TLSClientCert), getSource("tls-client-cert-file"))
|
||||
fmt.Printf(" tls-client-key = %s [%s]\n", mask("tls-client-key", cfg.TLSClientKey), getSource("tls-client-key"))
|
||||
fmt.Printf(" tls-client-ca = %v [%s]\n", cfg.TLSClientCAs, getSource("tls-client-ca"))
|
||||
fmt.Printf(" tls-client-cert = %s [%s] (deprecated PKCS12 path)\n", mask("tls-client-cert", cfg.TLSPrivateKey), getSource("tls-client-cert"))
|
||||
|
||||
fmt.Println("\nMetrics/Observability:")
|
||||
fmt.Printf(" metrics = %v [%s]\n", cfg.MetricsEnabled, getSource("metrics"))
|
||||
fmt.Printf(" otlp = %v [%s]\n", cfg.OTLPEnabled, getSource("otlp"))
|
||||
fmt.Printf(" metrics-admin-addr = %s [%s]\n", cfg.AdminAddr, getSource("metrics-admin-addr"))
|
||||
fmt.Printf(" metrics-async-bytes = %v [%s]\n", cfg.MetricsAsyncBytes, getSource("metrics-async-bytes"))
|
||||
fmt.Printf(" pprof = %v [%s]\n", cfg.PprofEnabled, getSource("pprof"))
|
||||
fmt.Printf(" region = %s [%s]\n", cfg.Region, getSource("region"))
|
||||
|
||||
fmt.Println("\n--- Source Legend ---")
|
||||
fmt.Println(" default = Built-in default value")
|
||||
fmt.Println(" file = Loaded from config file")
|
||||
fmt.Println(" environment = Set via environment variable")
|
||||
fmt.Println(" cli = Provided as command-line argument")
|
||||
fmt.Println("\nPriority: cli > environment > file > default")
|
||||
fmt.Println()
|
||||
}
|
||||
157
config_test.go
157
config_test.go
@@ -1,157 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// resetFlags allows flag.Parse() to be called again in each test, since
|
||||
// loadNewtConfig registers flags on the global flag.CommandLine.
|
||||
func resetFlags(t *testing.T) {
|
||||
t.Helper()
|
||||
oldArgs := os.Args
|
||||
oldCommandLine := flag.CommandLine
|
||||
t.Cleanup(func() {
|
||||
os.Args = oldArgs
|
||||
flag.CommandLine = oldCommandLine
|
||||
})
|
||||
flag.CommandLine = flag.NewFlagSet(os.Args[0], flag.ExitOnError)
|
||||
}
|
||||
|
||||
func clearNewtEnv(t *testing.T) {
|
||||
t.Helper()
|
||||
for _, k := range []string{
|
||||
"PANGOLIN_ENDPOINT", "NEWT_ID", "NEWT_SECRET", "DNS", "LOG_LEVEL",
|
||||
"MTU", "CONFIG_FILE", "NEWT_PROVISIONING_KEY", "NEWT_NAME",
|
||||
"DISABLE_SSH", "DISABLE_CLIENTS",
|
||||
} {
|
||||
t.Setenv(k, "")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadNewtConfig_Defaults(t *testing.T) {
|
||||
resetFlags(t)
|
||||
clearNewtEnv(t)
|
||||
os.Args = []string{"newt", "--config-file", filepath.Join(t.TempDir(), "missing.json")}
|
||||
|
||||
cfg := loadNewtConfig()
|
||||
|
||||
if cfg.DNS != "9.9.9.9" {
|
||||
t.Errorf("expected default dns, got %q", cfg.DNS)
|
||||
}
|
||||
if cfg.MTU != 1280 {
|
||||
t.Errorf("expected default mtu 1280, got %d", cfg.MTU)
|
||||
}
|
||||
if cfg.LogLevel != "INFO" {
|
||||
t.Errorf("expected default log level INFO, got %q", cfg.LogLevel)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadNewtConfig_FileOverridesDefault(t *testing.T) {
|
||||
resetFlags(t)
|
||||
clearNewtEnv(t)
|
||||
|
||||
configPath := filepath.Join(t.TempDir(), "config.json")
|
||||
if err := os.WriteFile(configPath, []byte(`{"dns":"1.1.1.1","mtu":1300,"disableSsh":true}`), 0o644); err != nil {
|
||||
t.Fatalf("failed to write config file: %v", err)
|
||||
}
|
||||
os.Args = []string{"newt", "--config-file", configPath}
|
||||
|
||||
cfg := loadNewtConfig()
|
||||
|
||||
if cfg.DNS != "1.1.1.1" {
|
||||
t.Errorf("expected dns from file, got %q", cfg.DNS)
|
||||
}
|
||||
if cfg.MTU != 1300 {
|
||||
t.Errorf("expected mtu from file, got %d", cfg.MTU)
|
||||
}
|
||||
if !cfg.DisableSSH {
|
||||
t.Errorf("expected disableSsh from file to be true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadNewtConfig_EnvOverridesFile(t *testing.T) {
|
||||
resetFlags(t)
|
||||
clearNewtEnv(t)
|
||||
|
||||
configPath := filepath.Join(t.TempDir(), "config.json")
|
||||
if err := os.WriteFile(configPath, []byte(`{"dns":"1.1.1.1"}`), 0o644); err != nil {
|
||||
t.Fatalf("failed to write config file: %v", err)
|
||||
}
|
||||
t.Setenv("DNS", "8.8.4.4")
|
||||
os.Args = []string{"newt", "--config-file", configPath}
|
||||
|
||||
cfg := loadNewtConfig()
|
||||
|
||||
if cfg.DNS != "8.8.4.4" {
|
||||
t.Errorf("expected env to override file dns, got %q", cfg.DNS)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadNewtConfig_CLIOverridesEnv(t *testing.T) {
|
||||
resetFlags(t)
|
||||
clearNewtEnv(t)
|
||||
|
||||
configPath := filepath.Join(t.TempDir(), "config.json")
|
||||
if err := os.WriteFile(configPath, []byte(`{"dns":"1.1.1.1"}`), 0o644); err != nil {
|
||||
t.Fatalf("failed to write config file: %v", err)
|
||||
}
|
||||
t.Setenv("DNS", "8.8.4.4")
|
||||
os.Args = []string{"newt", "--config-file", configPath, "--dns", "4.2.2.2"}
|
||||
|
||||
cfg := loadNewtConfig()
|
||||
|
||||
if cfg.DNS != "4.2.2.2" {
|
||||
t.Errorf("expected cli to override env dns, got %q", cfg.DNS)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadNewtConfig_TLSClientCAMergesAcrossSources(t *testing.T) {
|
||||
resetFlags(t)
|
||||
clearNewtEnv(t)
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
caFromFile := filepath.Join(tmpDir, "file-ca.pem")
|
||||
caFromEnv := filepath.Join(tmpDir, "env-ca.pem")
|
||||
caFromCLI := filepath.Join(tmpDir, "cli-ca.pem")
|
||||
|
||||
configPath := filepath.Join(tmpDir, "config.json")
|
||||
if err := os.WriteFile(configPath, []byte(`{"tlsClientCa":["`+caFromFile+`"]}`), 0o644); err != nil {
|
||||
t.Fatalf("failed to write config file: %v", err)
|
||||
}
|
||||
t.Setenv("TLS_CLIENT_CAS", caFromEnv)
|
||||
os.Args = []string{"newt", "--config-file", configPath, "--tls-client-ca", caFromCLI}
|
||||
|
||||
cfg := loadNewtConfig()
|
||||
|
||||
want := map[string]bool{caFromFile: true, caFromEnv: true, caFromCLI: true}
|
||||
if len(cfg.TLSClientCAs) != len(want) {
|
||||
t.Fatalf("expected %d CA entries, got %v", len(want), cfg.TLSClientCAs)
|
||||
}
|
||||
for _, ca := range cfg.TLSClientCAs {
|
||||
if !want[ca] {
|
||||
t.Errorf("unexpected CA entry: %s", ca)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveConfigFilePath_Precedence(t *testing.T) {
|
||||
t.Setenv("CONFIG_FILE", "")
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
|
||||
// CLI flag wins over env.
|
||||
t.Setenv("CONFIG_FILE", "/env/path/config.json")
|
||||
if got := resolveConfigFilePath([]string{"--config-file", "/cli/path/config.json"}); got != "/cli/path/config.json" {
|
||||
t.Errorf("expected cli path to win, got %q", got)
|
||||
}
|
||||
if got := resolveConfigFilePath([]string{"--config-file=/cli/eq/config.json"}); got != "/cli/eq/config.json" {
|
||||
t.Errorf("expected cli = path to win, got %q", got)
|
||||
}
|
||||
|
||||
// Env wins over default when no CLI flag given.
|
||||
if got := resolveConfigFilePath([]string{}); got != "/env/path/config.json" {
|
||||
t.Errorf("expected env path, got %q", got)
|
||||
}
|
||||
}
|
||||
@@ -9,9 +9,10 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/moby/moby/api/types/container"
|
||||
"github.com/moby/moby/api/types/events"
|
||||
"github.com/moby/moby/client"
|
||||
"github.com/docker/docker/api/types/container"
|
||||
"github.com/docker/docker/api/types/events"
|
||||
"github.com/docker/docker/api/types/filters"
|
||||
"github.com/docker/docker/client"
|
||||
"github.com/fosrl/newt/logger"
|
||||
)
|
||||
|
||||
@@ -169,7 +170,7 @@ func ListContainers(socketPath string, enforceNetworkValidation bool) ([]Contain
|
||||
}
|
||||
|
||||
// Used to filter down containers returned to Pangolin
|
||||
containerFilters := make(client.Filters)
|
||||
containerFilters := filters.NewArgs()
|
||||
|
||||
// Used to determine if we will send IP addresses or hostnames to Pangolin
|
||||
useContainerIpAddresses := true
|
||||
@@ -214,28 +215,21 @@ func ListContainers(socketPath string, enforceNetworkValidation bool) ([]Contain
|
||||
}
|
||||
|
||||
// List containers
|
||||
containerListResult, err := cli.ContainerList(ctx, client.ContainerListOptions{All: true, Filters: containerFilters})
|
||||
containers, err := cli.ContainerList(ctx, container.ListOptions{All: true, Filters: containerFilters})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list containers: %v", err)
|
||||
}
|
||||
|
||||
addrToString := func(addr interface{ IsValid() bool; String() string }) string {
|
||||
if addr.IsValid() {
|
||||
return addr.String()
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
var dockerContainers []Container
|
||||
for _, c := range containerListResult.Items {
|
||||
for _, c := range containers {
|
||||
// Short ID like docker ps
|
||||
shortId := c.ID[:12]
|
||||
|
||||
// Inspect container to get hostname
|
||||
hostname := ""
|
||||
containerInfo, err := cli.ContainerInspect(ctx, c.ID, client.ContainerInspectOptions{})
|
||||
if err == nil && containerInfo.Container.Config != nil {
|
||||
hostname = containerInfo.Container.Config.Hostname
|
||||
containerInfo, err := cli.ContainerInspect(ctx, c.ID)
|
||||
if err == nil && containerInfo.Config != nil {
|
||||
hostname = containerInfo.Config.Hostname
|
||||
}
|
||||
|
||||
// Skip host container if set
|
||||
@@ -259,8 +253,8 @@ func ListContainers(socketPath string, enforceNetworkValidation bool) ([]Contain
|
||||
if port.PublicPort != 0 {
|
||||
dockerPort.PublicPort = int(port.PublicPort)
|
||||
}
|
||||
if port.IP.IsValid() {
|
||||
dockerPort.IP = port.IP.String()
|
||||
if port.IP != "" {
|
||||
dockerPort.IP = port.IP
|
||||
}
|
||||
ports = append(ports, dockerPort)
|
||||
}
|
||||
@@ -274,19 +268,19 @@ func ListContainers(socketPath string, enforceNetworkValidation bool) ([]Contain
|
||||
dockerNetwork := Network{
|
||||
NetworkID: endpoint.NetworkID,
|
||||
EndpointID: endpoint.EndpointID,
|
||||
Gateway: addrToString(endpoint.Gateway),
|
||||
Gateway: endpoint.Gateway,
|
||||
IPPrefixLen: endpoint.IPPrefixLen,
|
||||
IPv6Gateway: addrToString(endpoint.IPv6Gateway),
|
||||
GlobalIPv6Address: addrToString(endpoint.GlobalIPv6Address),
|
||||
IPv6Gateway: endpoint.IPv6Gateway,
|
||||
GlobalIPv6Address: endpoint.GlobalIPv6Address,
|
||||
GlobalIPv6PrefixLen: endpoint.GlobalIPv6PrefixLen,
|
||||
MacAddress: endpoint.MacAddress.String(),
|
||||
MacAddress: endpoint.MacAddress,
|
||||
Aliases: endpoint.Aliases,
|
||||
DNSNames: endpoint.DNSNames,
|
||||
}
|
||||
|
||||
// Use IPs over hostnames/containers as we're on the bridge network
|
||||
if useContainerIpAddresses {
|
||||
dockerNetwork.IPAddress = addrToString(endpoint.IPAddress)
|
||||
dockerNetwork.IPAddress = endpoint.IPAddress
|
||||
}
|
||||
|
||||
networks[networkName] = dockerNetwork
|
||||
@@ -297,7 +291,7 @@ func ListContainers(socketPath string, enforceNetworkValidation bool) ([]Contain
|
||||
ID: shortId,
|
||||
Name: name,
|
||||
Image: c.Image,
|
||||
State: string(c.State),
|
||||
State: c.State,
|
||||
Status: c.Status,
|
||||
Ports: ports,
|
||||
Labels: c.Labels,
|
||||
@@ -321,12 +315,12 @@ func getHostContainer(dockerContext context.Context, dockerClient *client.Client
|
||||
}
|
||||
|
||||
// Get host container from the docker socket
|
||||
hostContainer, err := dockerClient.ContainerInspect(dockerContext, hostContainerName, client.ContainerInspectOptions{})
|
||||
hostContainer, err := dockerClient.ContainerInspect(dockerContext, hostContainerName)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to find host container")
|
||||
}
|
||||
|
||||
return &hostContainer.Container, nil
|
||||
return &hostContainer, nil
|
||||
}
|
||||
|
||||
// EventCallback defines the function signature for handling Docker events
|
||||
@@ -377,7 +371,7 @@ func (em *EventMonitor) Start() error {
|
||||
logger.Debug("Starting Docker event monitoring")
|
||||
|
||||
// Filter for container events we care about
|
||||
eventFilters := make(client.Filters)
|
||||
eventFilters := filters.NewArgs()
|
||||
eventFilters.Add("type", "container")
|
||||
// eventFilters.Add("event", "create")
|
||||
eventFilters.Add("event", "start")
|
||||
@@ -388,10 +382,9 @@ func (em *EventMonitor) Start() error {
|
||||
// eventFilters.Add("event", "unpause")
|
||||
|
||||
// Start listening for events
|
||||
eventsResult := em.client.Events(em.ctx, client.EventsListOptions{
|
||||
eventCh, errCh := em.client.Events(em.ctx, events.ListOptions{
|
||||
Filters: eventFilters,
|
||||
})
|
||||
eventCh, errCh := eventsResult.Messages, eventsResult.Err
|
||||
|
||||
go func() {
|
||||
defer func() {
|
||||
@@ -415,10 +408,9 @@ func (em *EventMonitor) Start() error {
|
||||
time.Sleep(5 * time.Second)
|
||||
if em.ctx.Err() == nil {
|
||||
logger.Info("Attempting to reconnect to Docker event stream")
|
||||
eventsResult = em.client.Events(em.ctx, client.EventsListOptions{
|
||||
eventCh, errCh = em.client.Events(em.ctx, events.ListOptions{
|
||||
Filters: eventFilters,
|
||||
})
|
||||
eventCh, errCh = eventsResult.Messages, eventsResult.Err
|
||||
}
|
||||
}
|
||||
return
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
inherit version;
|
||||
src = pkgs.nix-gitignore.gitignoreSource [ ] ./.;
|
||||
|
||||
vendorHash = "sha256-JhNBJhj5YX3Wurv7r/JDu6YtHizOMLk+NCob7ISx+3c=";
|
||||
vendorHash = "sha256-WfIK+Q8WQ372NzLw6DRapv1nYPduShi4KnVJBPk0Oz0=";
|
||||
|
||||
nativeInstallCheckInputs = [ pkgs.versionCheckHook ];
|
||||
|
||||
|
||||
@@ -273,7 +273,7 @@ main() {
|
||||
CUSTOM_PATH=$(parse_path_arg "$@")
|
||||
|
||||
if [ -n "$CUSTOM_PATH" ]; then
|
||||
print_status "Installing latest version of newt to ${CUSTOM_PATH}..."
|
||||
print_status "Installing latest version of newt to ${CUSTOM_PATH} (--path override)..."
|
||||
else
|
||||
print_status "Installing latest version of newt..."
|
||||
fi
|
||||
@@ -331,4 +331,4 @@ main() {
|
||||
}
|
||||
|
||||
# Run main function
|
||||
main "$@"
|
||||
main "$@"
|
||||
70
go.mod
70
go.mod
@@ -3,73 +3,75 @@ module github.com/fosrl/newt
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/coder/websocket v1.8.15
|
||||
github.com/creack/pty v1.1.24
|
||||
github.com/fsnotify/fsnotify v1.9.0
|
||||
github.com/gaissmai/bart v0.28.0
|
||||
github.com/go-crypt/crypt v0.14.15
|
||||
github.com/go-crypt/x v0.4.16
|
||||
github.com/docker/docker v28.5.2+incompatible
|
||||
github.com/gaissmai/bart v0.26.1
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
github.com/moby/moby/api v1.55.0
|
||||
github.com/moby/moby/client v0.5.0
|
||||
github.com/prometheus/client_golang v1.23.2
|
||||
github.com/vishvananda/netlink v1.3.1
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0
|
||||
go.opentelemetry.io/contrib/instrumentation/runtime v0.69.0
|
||||
go.opentelemetry.io/otel v1.44.0
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.44.0
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.44.0
|
||||
go.opentelemetry.io/otel/exporters/prometheus v0.66.0
|
||||
go.opentelemetry.io/otel/metric v1.44.0
|
||||
go.opentelemetry.io/otel/sdk v1.44.0
|
||||
go.opentelemetry.io/otel/sdk/metric v1.44.0
|
||||
golang.org/x/crypto v0.53.0
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0
|
||||
go.opentelemetry.io/contrib/instrumentation/runtime v0.68.0
|
||||
go.opentelemetry.io/otel v1.43.0
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.43.0
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.43.0
|
||||
go.opentelemetry.io/otel/exporters/prometheus v0.65.0
|
||||
go.opentelemetry.io/otel/metric v1.43.0
|
||||
go.opentelemetry.io/otel/sdk v1.43.0
|
||||
go.opentelemetry.io/otel/sdk/metric v1.43.0
|
||||
golang.org/x/crypto v0.50.0
|
||||
golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6
|
||||
golang.org/x/net v0.56.0
|
||||
golang.org/x/sys v0.46.0
|
||||
golang.org/x/net v0.53.0
|
||||
golang.org/x/sys v0.43.0
|
||||
golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb
|
||||
golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10
|
||||
golang.zx2c4.com/wireguard/windows v1.0.1
|
||||
google.golang.org/grpc v1.82.0
|
||||
golang.zx2c4.com/wireguard/windows v0.5.3
|
||||
google.golang.org/grpc v1.81.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
gvisor.dev/gvisor v0.0.0-20250503011706-39ed1f5ac29c
|
||||
software.sslmate.com/src/go-pkcs12 v0.7.3
|
||||
software.sslmate.com/src/go-pkcs12 v0.7.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/Microsoft/go-winio v0.6.2 // indirect
|
||||
github.com/Microsoft/go-winio v0.6.0 // indirect
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/containerd/errdefs v1.0.0 // indirect
|
||||
github.com/containerd/errdefs v0.3.0 // indirect
|
||||
github.com/containerd/errdefs/pkg v0.3.0 // indirect
|
||||
github.com/distribution/reference v0.6.0 // indirect
|
||||
github.com/docker/go-connections v0.7.0 // indirect
|
||||
github.com/docker/go-units v0.5.0 // indirect
|
||||
github.com/docker/go-connections v0.6.0 // indirect
|
||||
github.com/docker/go-units v0.4.0 // indirect
|
||||
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||
github.com/go-logr/logr v1.4.3 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/google/btree v1.1.3 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 // indirect
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect
|
||||
github.com/moby/docker-image-spec v1.3.1 // indirect
|
||||
github.com/moby/sys/atomicwriter v0.1.0 // indirect
|
||||
github.com/moby/term v0.5.2 // indirect
|
||||
github.com/morikuni/aec v1.0.0 // indirect
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||
github.com/opencontainers/go-digest v1.0.0 // indirect
|
||||
github.com/opencontainers/image-spec v1.1.1 // indirect
|
||||
github.com/opencontainers/image-spec v1.1.0 // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/prometheus/client_model v0.6.2 // indirect
|
||||
github.com/prometheus/common v0.67.5 // indirect
|
||||
github.com/prometheus/otlptranslator v1.0.0 // indirect
|
||||
github.com/prometheus/procfs v0.20.1 // indirect
|
||||
github.com/vishvananda/netns v0.0.5 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.44.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.43.0 // indirect
|
||||
go.opentelemetry.io/proto/otlp v1.10.0 // indirect
|
||||
go.yaml.in/yaml/v2 v2.4.4 // indirect
|
||||
golang.org/x/text v0.38.0 // indirect
|
||||
golang.org/x/mod v0.34.0 // indirect
|
||||
golang.org/x/sync v0.20.0 // indirect
|
||||
golang.org/x/text v0.36.0 // indirect
|
||||
golang.org/x/time v0.12.0 // indirect
|
||||
golang.org/x/tools v0.43.0 // indirect
|
||||
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect
|
||||
google.golang.org/protobuf v1.36.11 // indirect
|
||||
)
|
||||
|
||||
158
go.sum
158
go.sum
@@ -1,37 +1,33 @@
|
||||
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
||||
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
||||
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg=
|
||||
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
|
||||
github.com/Microsoft/go-winio v0.6.0 h1:slsWYD/zyx7lCXoZVlvQrj0hPTM1HI4+v1sIda2yDvg=
|
||||
github.com/Microsoft/go-winio v0.6.0/go.mod h1:cTAf44im0RAYeL23bpB+fzCyDH2MJiz2BO69KH/soAE=
|
||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||
github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
|
||||
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNUA=
|
||||
github.com/coder/websocket v1.8.15/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
|
||||
github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
|
||||
github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
|
||||
github.com/containerd/errdefs v0.3.0 h1:FSZgGOeK4yuT/+DnF07/Olde/q4KBoMsaamhXxIMDp4=
|
||||
github.com/containerd/errdefs v0.3.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
|
||||
github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
|
||||
github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk=
|
||||
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
|
||||
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
|
||||
github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I=
|
||||
github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
|
||||
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
|
||||
github.com/docker/go-connections v0.7.0 h1:6SsRfJddP22WMrCkj19x9WKjEDTB+ahsdiGYf0mN39c=
|
||||
github.com/docker/go-connections v0.7.0/go.mod h1:no1qkHdjq7kLMGUXYAduOhYPSJxxvgWBh7ogVvptn3Q=
|
||||
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
|
||||
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
|
||||
github.com/docker/docker v28.5.2+incompatible h1:DBX0Y0zAjZbSrm1uzOkdr1onVghKaftjlSWt4AFexzM=
|
||||
github.com/docker/docker v28.5.2+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
|
||||
github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94=
|
||||
github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE=
|
||||
github.com/docker/go-units v0.4.0 h1:3uh0PgVws3nIA0Q+MwDC8yjEPf9zjRfZZWXZYDct3Tw=
|
||||
github.com/docker/go-units v0.4.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
|
||||
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
||||
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||
github.com/gaissmai/bart v0.28.0 h1:89yZLo8NmyqD0RYgJ3QO9HhqqGGw+oWhf90cZm69Lko=
|
||||
github.com/gaissmai/bart v0.28.0/go.mod h1:GREWQfTLRWz/c5FTOsIw+KkscuFkIV5t8Rp7Nd1Td5c=
|
||||
github.com/go-crypt/crypt v0.14.15 h1:q1i5OMpL05r935IxWmXgpDAVF0nvi4SMoHhGXLBQUEQ=
|
||||
github.com/go-crypt/crypt v0.14.15/go.mod h1:0n/to1VqIZPENj2yEUa/sLLYYnmupma6cp+QMX4zfF0=
|
||||
github.com/go-crypt/x v0.4.16 h1:WXdY28H/0MsXnH+gwerxuCcvBTJPkBG90u6oS4gIPZI=
|
||||
github.com/go-crypt/x v0.4.16/go.mod h1:vmVFA/d/oLrEaCbqsLcjBMlTqF8u8pvH/c4+EJ/ped8=
|
||||
github.com/gaissmai/bart v0.26.1 h1:+w4rnLGNlA2GDVn382Tfe3jOsK5vOr5n4KmigJ9lbTo=
|
||||
github.com/gaissmai/bart v0.26.1/go.mod h1:GREWQfTLRWz/c5FTOsIw+KkscuFkIV5t8Rp7Nd1Td5c=
|
||||
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
@@ -47,8 +43,8 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 h1:5VipnvEpbqr2gA2VbM+nYVbkIF28c5ZQfqCBQ5g2xfk=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0/go.mod h1:Hyl3n6Twe1hvtd9XUXDec4pTvgMSEixRuQKPTMH2bNs=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz+PMpZ14Jynv3O2Zs=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c=
|
||||
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
|
||||
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
@@ -59,16 +55,22 @@ github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0
|
||||
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
|
||||
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
|
||||
github.com/moby/moby/api v1.55.0 h1:2/sexvQyqIWS8pRSCFddBfpW2qE7vR7FCL+vN8pxwMc=
|
||||
github.com/moby/moby/api v1.55.0/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs=
|
||||
github.com/moby/moby/client v0.5.0 h1:5XhyPk2fuOWf6RlSFa3MkIIgDZkF25xToXW8Q/BH7cc=
|
||||
github.com/moby/moby/client v0.5.0/go.mod h1:rcVpF8ncl9vo5gaIBdol6CnbEtSj1uxMvEV/UrykF/s=
|
||||
github.com/moby/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw=
|
||||
github.com/moby/sys/atomicwriter v0.1.0/go.mod h1:Ul8oqv2ZMNHOceF643P6FKPXeCmYtlQMvpizfsSoaWs=
|
||||
github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU=
|
||||
github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko=
|
||||
github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ=
|
||||
github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc=
|
||||
github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
|
||||
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
||||
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
|
||||
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
|
||||
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
|
||||
github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
|
||||
github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug=
|
||||
github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
|
||||
@@ -83,6 +85,8 @@ github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEy
|
||||
github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo=
|
||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
|
||||
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/vishvananda/netlink v1.3.1 h1:3AEMt62VKqz90r0tmNhog0r/PpWKmrEShJU0wJW6bV0=
|
||||
@@ -91,68 +95,72 @@ github.com/vishvananda/netns v0.0.5 h1:DfiHV+j8bA32MFM7bfEunvT8IAqQ/NzSJHtcmW5zd
|
||||
github.com/vishvananda/netns v0.0.5/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 h1:8tvICD4vSTOOsNrsI4Ljf6C+6UKvpTEH5XY3JMoyPoo=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0/go.mod h1:z9+yiacE0IHRqM4qFfkbt/JYlmYXgss8GY/jXoNuPJI=
|
||||
go.opentelemetry.io/contrib/instrumentation/runtime v0.69.0 h1:MtkMsuRo3zEXTTMALfyrszwCDZTkB6wolyPjbwFAdq0=
|
||||
go.opentelemetry.io/contrib/instrumentation/runtime v0.69.0/go.mod h1:FYTxnpsm+UPD0erZNq20GvnM8T2YQHiHtT2vokdpoac=
|
||||
go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU=
|
||||
go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.44.0 h1:SUplec5dp06reu1zaXmOXdvqH398taqrDXqUl99jxSc=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.44.0/go.mod h1:ho2g4N+ane+swq5I/VBkKWnRDY4kUINH3FuqyZqX/Ug=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0 h1:4YsVu3B8+3qtWYYrsUYgn0OG78pN0rnNPRGX4SbokQI=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0/go.mod h1:+wnlSn0mD1ADVMe3v9Z/WIaiz6q6gL2J/ejaAmdmv80=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.44.0 h1:qazEJlUOQzhCpzQpFETGby7EdqjI1wsd0W+6Gg1SCTU=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.44.0/go.mod h1:fOD2Yefuxixkx3ahVNf0O/PERb6r4OlbxfATVnYvzCo=
|
||||
go.opentelemetry.io/otel/exporters/prometheus v0.66.0 h1:vkrK8PAznv2NKt2r+kdu252ccGzkEqLc2aSXbQIALYQ=
|
||||
go.opentelemetry.io/otel/exporters/prometheus v0.66.0/go.mod h1:V/UB6D3vMF/UBOL5igAsAYnk1nG/bzYYTzvsB16cy7o=
|
||||
go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc=
|
||||
go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo=
|
||||
go.opentelemetry.io/otel/metric/x v0.66.0 h1:YkCrx1zLOChi9ZcZ6euupOcsgzbVlec7D/xoEU1+cTA=
|
||||
go.opentelemetry.io/otel/metric/x v0.66.0/go.mod h1:d1+BDj9t96do0/1LoU1ayfCv79ZgNE41qbhBvnMOBZk=
|
||||
go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58=
|
||||
go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA=
|
||||
go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk=
|
||||
go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 h1:CqXxU8VOmDefoh0+ztfGaymYbhdB/tT3zs79QaZTNGY=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0/go.mod h1:BuhAPThV8PBHBvg8ZzZ/Ok3idOdhWIodywz2xEcRbJo=
|
||||
go.opentelemetry.io/contrib/instrumentation/runtime v0.68.0 h1:jhVIQEprwUTV+KfzzliLidclhoTOoHTgdz96kAyR8mU=
|
||||
go.opentelemetry.io/contrib/instrumentation/runtime v0.68.0/go.mod h1:4HsdbLUbernaTnA8CNaNE+1g026SciXb3juRYe3l8EY=
|
||||
go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
|
||||
go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.43.0 h1:8UQVDcZxOJLtX6gxtDt3vY2WTgvZqMQRzjsqiIHQdkc=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.43.0/go.mod h1:2lmweYCiHYpEjQ/lSJBYhj9jP1zvCvQW4BqL9dnT7FQ=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 h1:88Y4s2C8oTui1LGM6bTWkw0ICGcOLCAI5l6zsD1j20k=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0/go.mod h1:Vl1/iaggsuRlrHf/hfPJPvVag77kKyvrLeD10kpMl+A=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.43.0 h1:RAE+JPfvEmvy+0LzyUA25/SGawPwIUbZ6u0Wug54sLc=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.43.0/go.mod h1:AGmbycVGEsRx9mXMZ75CsOyhSP6MFIcj/6dnG+vhVjk=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0 h1:3iZJKlCZufyRzPzlQhUIWVmfltrXuGyfjREgGP3UUjc=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0/go.mod h1:/G+nUPfhq2e+qiXMGxMwumDrP5jtzU+mWN7/sjT2rak=
|
||||
go.opentelemetry.io/otel/exporters/prometheus v0.65.0 h1:jOveH/b4lU9HT7y+Gfamf18BqlOuz2PWEvs8yM7Q6XE=
|
||||
go.opentelemetry.io/otel/exporters/prometheus v0.65.0/go.mod h1:i1P8pcumauPtUI4YNopea1dhzEMuEqWP1xoUZDylLHo=
|
||||
go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM=
|
||||
go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY=
|
||||
go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg=
|
||||
go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A=
|
||||
go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A=
|
||||
go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0=
|
||||
go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g=
|
||||
go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ=
|
||||
go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ=
|
||||
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
|
||||
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
|
||||
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
|
||||
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
|
||||
golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6 h1:zfMcR1Cs4KNuomFFgGefv5N0czO2XZpUbxGUy8i8ug0=
|
||||
golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6/go.mod h1:46edojNIoXTNOhySWIWdix628clX9ODXwPsQuG6hsK0=
|
||||
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
|
||||
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
|
||||
golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI=
|
||||
golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY=
|
||||
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
|
||||
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
|
||||
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc=
|
||||
golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y=
|
||||
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
|
||||
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
|
||||
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
|
||||
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
|
||||
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
|
||||
golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
|
||||
golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
|
||||
golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s=
|
||||
golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0=
|
||||
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeunTOisW56dUokqW/FOteYJJ/yg=
|
||||
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI=
|
||||
golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb h1:whnFRlWMcXI9d+ZbWg+4sHnLp52d5yiIPUxMBSt4X9A=
|
||||
golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb/go.mod h1:rpwXGsirqLqN2L0JDJQlwOboGHmptD5ZD6T2VmcqhTw=
|
||||
golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10 h1:3GDAcqdIg1ozBNLgPy4SLT84nfcBjr6rhGtXYtrkWLU=
|
||||
golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10/go.mod h1:T97yPqesLiNrOYxkwmhMI0ZIlJDm+p0PMR8eRVeR5tQ=
|
||||
golang.zx2c4.com/wireguard/windows v1.0.1 h1:eOxiDVbywPC+ZQqvdCK7x+ZwWXKbYv50TtH8ysFIbw8=
|
||||
golang.zx2c4.com/wireguard/windows v1.0.1/go.mod h1:+fbT3FFdX4zzYDLwJh5+HPEcNN/3HyNdzhNSVsQM+zs=
|
||||
golang.zx2c4.com/wireguard/windows v0.5.3 h1:On6j2Rpn3OEMXqBq00QEDC7bWSZrPIHKIus8eIuExIE=
|
||||
golang.zx2c4.com/wireguard/windows v0.5.3/go.mod h1:9TEe8TJmtwyQebdFwAkEWOPr3prrtqm+REGFifP60hI=
|
||||
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
|
||||
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa h1:Kjn0N0tCrDgiAFW+lGO4JZ3ck44CehvJQMAwj9QF0G8=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:q4lMZS6kskjT5HvCPrnnypcDPVJqT/f4nfxmkE7gryY=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||
google.golang.org/grpc v1.82.0 h1:vguDnZUPjE26w09A63VoxZPnvPjB5Riyc0mkXPFmAIU=
|
||||
google.golang.org/grpc v1.82.0/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 h1:VPWxll4HlMw1Vs/qXtN7BvhZqsS9cdAittCNvVENElA=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:7QBABkRtR8z+TEnmXTqIqwJLlzrZKVfAUm7tY3yGv0M=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 h1:m8qni9SQFH0tJc1X0vmnpw/0t+AImlSvp30sEupozUg=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||
google.golang.org/grpc v1.81.0 h1:W3G9N3KQf3BU+YuCtGKJk0CmxQNbAISICD/9AORxLIw=
|
||||
google.golang.org/grpc v1.81.0/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
@@ -160,11 +168,9 @@ gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntN
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q=
|
||||
gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA=
|
||||
gotest.tools/v3 v3.4.0 h1:ZazjZUfuVeZGLAmlKKuyv3IKP5orXcwtOwDQH6YVr6o=
|
||||
gotest.tools/v3 v3.4.0/go.mod h1:CtbdzLSsqVhDgMtKsx03ird5YTGB3ar27v0u/yKBW5g=
|
||||
gvisor.dev/gvisor v0.0.0-20250503011706-39ed1f5ac29c h1:m/r7OM+Y2Ty1sgBQ7Qb27VgIMBW8ZZhT4gLnUyDIhzI=
|
||||
gvisor.dev/gvisor v0.0.0-20250503011706-39ed1f5ac29c/go.mod h1:3r5CMtNQMKIvBlrmM9xWUNamjKBYPOWyXOjmg5Kts3g=
|
||||
pgregory.net/rapid v1.2.0 h1:keKAYRcjm+e1F0oAuU5F5+YPAWcyxNNRK2wud503Gnk=
|
||||
pgregory.net/rapid v1.2.0/go.mod h1:PY5XlDGj0+V1FCq0o192FdRhpKHGTRIWBgqjDBTrq04=
|
||||
software.sslmate.com/src/go-pkcs12 v0.7.3 h1:JBQD3FDqYjTeyDAeZQklj2ar88ykBLtALloPJHyAauU=
|
||||
software.sslmate.com/src/go-pkcs12 v0.7.3/go.mod h1:Qiz0EyvDRJjjxGyUQa2cCNZn/wMyzrRJ/qcDXOQazLI=
|
||||
software.sslmate.com/src/go-pkcs12 v0.7.0 h1:Db8W44cB54TWD7stUFFSWxdfpdn6fZVcDl0w3R4RVM0=
|
||||
software.sslmate.com/src/go-pkcs12 v0.7.0/go.mod h1:Qiz0EyvDRJjjxGyUQa2cCNZn/wMyzrRJ/qcDXOQazLI=
|
||||
|
||||
@@ -212,10 +212,6 @@ func (m *Monitor) addTargetUnsafe(config Config) error {
|
||||
return nil
|
||||
}(),
|
||||
Transport: &http.Transport{
|
||||
// Disable keep-alives so each health check uses a fresh connection.
|
||||
// Reusing idle connections causes spurious timeouts when the remote
|
||||
// server closes the connection between long check intervals.
|
||||
DisableKeepAlives: true,
|
||||
TLSClientConfig: &tls.Config{
|
||||
// Configure TLS settings based on certificate enforcement
|
||||
InsecureSkipVerify: !m.enforceCert,
|
||||
@@ -436,9 +432,7 @@ func (m *Monitor) performTCPCheck(target *Target) (bool, string) {
|
||||
logger.Debug("Target %d: performing TCP health check to %s (timeout: %v)",
|
||||
target.Config.ID, address, timeout)
|
||||
|
||||
ctx, cancel := context.WithTimeout(target.ctx, timeout)
|
||||
defer cancel()
|
||||
conn, err := (&net.Dialer{}).DialContext(ctx, "tcp", address)
|
||||
conn, err := net.DialTimeout("tcp", address, timeout)
|
||||
if err != nil {
|
||||
msg := fmt.Sprintf("TCP dial failed: %v", err)
|
||||
logger.Warn("Target %d: %s", target.Config.ID, msg)
|
||||
@@ -473,9 +467,8 @@ func (m *Monitor) performHTTPCheck(target *Target) (bool, string) {
|
||||
target.Config.ID, m.enforceCert)
|
||||
}
|
||||
|
||||
// Create request with timeout context, derived from the target's context so
|
||||
// that in-flight requests are cancelled immediately when the target is replaced.
|
||||
ctx, cancel := context.WithTimeout(target.ctx, time.Duration(target.Config.Timeout)*time.Second)
|
||||
// Create request with timeout context
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(target.Config.Timeout)*time.Second)
|
||||
defer cancel()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, target.Config.Method, url, nil)
|
||||
|
||||
@@ -27,17 +27,17 @@ type ExitNode struct {
|
||||
|
||||
// Manager handles UDP hole punching operations
|
||||
type Manager struct {
|
||||
mu sync.Mutex
|
||||
running bool
|
||||
stopChan chan struct{}
|
||||
sharedBind *bind.SharedBind
|
||||
ID string
|
||||
token string
|
||||
publicKey string
|
||||
clientType string
|
||||
exitNodes map[string]ExitNode // key is endpoint
|
||||
updateChan chan struct{} // signals the goroutine to refresh exit nodes
|
||||
publicDNS []string
|
||||
mu sync.Mutex
|
||||
running bool
|
||||
stopChan chan struct{}
|
||||
sharedBind *bind.SharedBind
|
||||
ID string
|
||||
token string
|
||||
publicKey string
|
||||
clientType string
|
||||
exitNodes map[string]ExitNode // key is endpoint
|
||||
updateChan chan struct{} // signals the goroutine to refresh exit nodes
|
||||
publicDNS []string
|
||||
|
||||
sendHolepunchInterval time.Duration
|
||||
sendHolepunchIntervalMin time.Duration
|
||||
@@ -56,7 +56,7 @@ func NewManager(sharedBind *bind.SharedBind, ID string, clientType string, publi
|
||||
ID: ID,
|
||||
clientType: clientType,
|
||||
publicKey: publicKey,
|
||||
publicDNS: publicDNS,
|
||||
publicDNS: publicDNS,
|
||||
exitNodes: make(map[string]ExitNode),
|
||||
sendHolepunchInterval: defaultSendHolepunchIntervalMin,
|
||||
sendHolepunchIntervalMin: defaultSendHolepunchIntervalMin,
|
||||
@@ -73,14 +73,6 @@ func (m *Manager) SetToken(token string) {
|
||||
m.token = token
|
||||
}
|
||||
|
||||
// SetPublicDNS replaces the list of DNS servers used to resolve exit-node
|
||||
// endpoints. The servers must be in "host:port" format (e.g. "8.8.8.8:53").
|
||||
func (m *Manager) SetPublicDNS(servers []string) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.publicDNS = servers
|
||||
}
|
||||
|
||||
// IsRunning returns whether hole punching is currently active
|
||||
func (m *Manager) IsRunning() bool {
|
||||
m.mu.Lock()
|
||||
|
||||
@@ -43,17 +43,17 @@ func DefaultTestOptions() TestConnectionOptions {
|
||||
|
||||
// cachedAddr holds a cached resolved UDP address
|
||||
type cachedAddr struct {
|
||||
addr *net.UDPAddr
|
||||
addr *net.UDPAddr
|
||||
resolvedAt time.Time
|
||||
}
|
||||
|
||||
// HolepunchTester monitors holepunch connectivity using magic packets
|
||||
type HolepunchTester struct {
|
||||
sharedBind *bind.SharedBind
|
||||
publicDNS []string
|
||||
mu sync.RWMutex
|
||||
running bool
|
||||
stopChan chan struct{}
|
||||
sharedBind *bind.SharedBind
|
||||
publicDNS []string
|
||||
mu sync.RWMutex
|
||||
running bool
|
||||
stopChan chan struct{}
|
||||
|
||||
// Pending requests waiting for responses (key: echo data as string)
|
||||
pendingRequests sync.Map // map[string]*pendingRequest
|
||||
@@ -88,24 +88,12 @@ type pendingRequest struct {
|
||||
func NewHolepunchTester(sharedBind *bind.SharedBind, publicDNS []string) *HolepunchTester {
|
||||
return &HolepunchTester{
|
||||
sharedBind: sharedBind,
|
||||
publicDNS: publicDNS,
|
||||
publicDNS: publicDNS,
|
||||
addrCache: make(map[string]*cachedAddr),
|
||||
addrCacheTTL: 5 * time.Minute, // Cache addresses for 5 minutes
|
||||
}
|
||||
}
|
||||
|
||||
// SetPublicDNS replaces the list of DNS servers used to resolve peer endpoints.
|
||||
// The servers must be in "host:port" format (e.g. "8.8.8.8:53").
|
||||
func (t *HolepunchTester) SetPublicDNS(servers []string) {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
t.publicDNS = servers
|
||||
// Invalidate the address cache so endpoints are re-resolved with the new DNS.
|
||||
t.addrCacheMu.Lock()
|
||||
t.addrCache = make(map[string]*cachedAddr)
|
||||
t.addrCacheMu.Unlock()
|
||||
}
|
||||
|
||||
// SetCallback sets the callback for connection status changes
|
||||
func (t *HolepunchTester) SetCallback(callback HolepunchStatusCallback) {
|
||||
t.mu.Lock()
|
||||
|
||||
@@ -1,158 +0,0 @@
|
||||
package nativessh
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"os/user"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/fosrl/newt/logger"
|
||||
"golang.org/x/crypto/ssh"
|
||||
)
|
||||
|
||||
type staticConnMeta struct {
|
||||
user string
|
||||
}
|
||||
|
||||
func (m staticConnMeta) User() string { return m.user }
|
||||
func (m staticConnMeta) SessionID() []byte { return nil }
|
||||
func (m staticConnMeta) ClientVersion() []byte { return nil }
|
||||
func (m staticConnMeta) ServerVersion() []byte { return nil }
|
||||
func (m staticConnMeta) RemoteAddr() net.Addr { return nil }
|
||||
func (m staticConnMeta) LocalAddr() net.Addr { return nil }
|
||||
|
||||
// CheckAuthorizedKeys reports whether key matches any entry in the system
|
||||
// user's ~/.ssh/authorized_keys file. Returns false (not an error) when the
|
||||
// user or file does not exist.
|
||||
func CheckAuthorizedKeys(username string, key ssh.PublicKey) bool {
|
||||
u, err := user.Lookup(username)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
f, err := os.Open(filepath.Join(u.HomeDir, ".ssh", "authorized_keys"))
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
want := ssh.FingerprintSHA256(key)
|
||||
scanner := bufio.NewScanner(f)
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
parsed, _, _, _, err := ssh.ParseAuthorizedKey([]byte(line))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if ssh.FingerprintSHA256(parsed) == want {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// SystemUserExists reports whether a user account with the given name exists
|
||||
// on the host OS.
|
||||
func SystemUserExists(username string) bool {
|
||||
_, err := user.Lookup(username)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// Authenticate authenticates a user for a browser-based native SSH session.
|
||||
// It tries, in order:
|
||||
// 1. Private key — parses privateKeyPEM and checks it against the user's
|
||||
// ~/.ssh/authorized_keys.
|
||||
// 2. Password — verifies password via the host OS PAM stack (Linux only).
|
||||
//
|
||||
// Returns nil on the first method that succeeds, or an error if all fail.
|
||||
func Authenticate(username, password, privateKeyPEM string) error {
|
||||
return AuthenticateWithCertificate(nil, username, password, privateKeyPEM, "")
|
||||
}
|
||||
|
||||
// AuthenticateWithCertificate authenticates a user for a browser-based native
|
||||
// SSH session using the same method ordering as the native SSH server:
|
||||
// 1. Private key in host ~/.ssh/authorized_keys.
|
||||
// 2. SSH certificate signed by the configured CA (when provided).
|
||||
// 3. Password via host PAM stack.
|
||||
func AuthenticateWithCertificate(store *CredentialStore, username, password, privateKeyPEM, certificate string) error {
|
||||
logger.Debug("nativessh: authenticating user %q (hasPassword=%v, hasPrivateKey=%v)", username, password != "", privateKeyPEM != "")
|
||||
if !SystemUserExists(username) {
|
||||
logger.Debug("nativessh: user %q not found on system", username)
|
||||
return fmt.Errorf("user %q does not exist", username)
|
||||
}
|
||||
|
||||
var signer ssh.Signer
|
||||
if privateKeyPEM != "" {
|
||||
parsedSigner, err := ssh.ParsePrivateKey([]byte(privateKeyPEM))
|
||||
if err != nil {
|
||||
logger.Debug("nativessh: failed to parse private key for %q: %v", username, err)
|
||||
} else if CheckAuthorizedKeys(username, parsedSigner.PublicKey()) {
|
||||
logger.Debug("nativessh: private key auth succeeded for %q", username)
|
||||
return nil
|
||||
} else {
|
||||
signer = parsedSigner
|
||||
logger.Debug("nativessh: private key not in authorized_keys for %q", username)
|
||||
}
|
||||
}
|
||||
|
||||
if store != nil && certificate != "" {
|
||||
if signer == nil {
|
||||
logger.Debug("nativessh: certificate provided for %q but no matching private key was provided", username)
|
||||
} else {
|
||||
pub, _, _, _, err := ssh.ParseAuthorizedKey([]byte(certificate))
|
||||
if err != nil {
|
||||
logger.Debug("nativessh: failed to parse certificate for %q: %v", username, err)
|
||||
} else {
|
||||
cert, ok := pub.(*ssh.Certificate)
|
||||
if !ok {
|
||||
logger.Debug("nativessh: provided cert data for %q is not an SSH certificate", username)
|
||||
} else if ssh.FingerprintSHA256(cert.Key) != ssh.FingerprintSHA256(signer.PublicKey()) {
|
||||
logger.Debug("nativessh: certificate key mismatch for %q", username)
|
||||
} else {
|
||||
caKey, userPrincipals := store.get(username)
|
||||
if caKey == nil {
|
||||
logger.Debug("nativessh: CA key is not set for certificate auth user %q", username)
|
||||
} else if len(userPrincipals) == 0 {
|
||||
logger.Debug("nativessh: no allowed principals found for certificate auth user %q", username)
|
||||
} else {
|
||||
checker := &ssh.CertChecker{
|
||||
IsUserAuthority: func(auth ssh.PublicKey) bool {
|
||||
return ssh.FingerprintSHA256(auth) == ssh.FingerprintSHA256(caKey)
|
||||
},
|
||||
}
|
||||
|
||||
var lastErr error
|
||||
for principal := range userPrincipals {
|
||||
_, authErr := checker.Authenticate(staticConnMeta{user: principal}, cert)
|
||||
if authErr == nil {
|
||||
logger.Debug("nativessh: certificate auth succeeded for %q (principal=%q)", username, principal)
|
||||
return nil
|
||||
}
|
||||
lastErr = authErr
|
||||
}
|
||||
if lastErr != nil {
|
||||
logger.Debug("nativessh: certificate auth failed for %q: %v", username, lastErr)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if password != "" {
|
||||
if err := VerifySystemPassword(username, password); err != nil {
|
||||
logger.Debug("nativessh: password auth failed for %q: %v", username, err)
|
||||
} else {
|
||||
logger.Debug("nativessh: password auth succeeded for %q", username)
|
||||
return nil
|
||||
}
|
||||
} else {
|
||||
logger.Debug("nativessh: no password provided for %q", username)
|
||||
}
|
||||
return fmt.Errorf("authentication failed for user %q", username)
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
//go:build linux
|
||||
|
||||
package nativessh
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/fosrl/newt/logger"
|
||||
"github.com/go-crypt/crypt"
|
||||
"github.com/go-crypt/x/yescrypt"
|
||||
)
|
||||
|
||||
// VerifySystemPassword authenticates username/password by reading /etc/shadow.
|
||||
// Supports yescrypt ($y$), bcrypt ($2b$/$2a$/$2y$), SHA-512 ($6$), SHA-256
|
||||
// ($5$), argon2, scrypt, and other schemes handled by go-crypt/crypt.
|
||||
func VerifySystemPassword(username, password string) error {
|
||||
hash, err := readShadowHash(username)
|
||||
if err != nil {
|
||||
logger.Debug("nativessh/pam: readShadowHash for %q failed: %v", username, err)
|
||||
return fmt.Errorf("shadow: %w", err)
|
||||
}
|
||||
|
||||
// Log the scheme prefix only (never the full hash).
|
||||
scheme := "unknown"
|
||||
for _, prefix := range []string{"$y$", "$2a$", "$2b$", "$2y$", "$6$", "$5$", "$1$"} {
|
||||
if strings.HasPrefix(hash, prefix) {
|
||||
scheme = prefix
|
||||
break
|
||||
}
|
||||
}
|
||||
logger.Debug("nativessh/pam: verifying password for %q using scheme %s", username, scheme)
|
||||
|
||||
// Yescrypt ($y$) is not in go-crypt/crypt's default decoder; handle it directly.
|
||||
if strings.HasPrefix(hash, "$y$") {
|
||||
computed, err := yescrypt.Hash([]byte(password), []byte(hash))
|
||||
if err != nil {
|
||||
logger.Debug("nativessh/pam: yescrypt.Hash for %q failed: %v", username, err)
|
||||
return fmt.Errorf("yescrypt: %w", err)
|
||||
}
|
||||
if !bytes.Equal(computed, []byte(hash)) {
|
||||
logger.Debug("nativessh/pam: yescrypt mismatch for %q", username)
|
||||
return errors.New("authentication failed")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
decoder, err := crypt.NewDefaultDecoder()
|
||||
if err != nil {
|
||||
return fmt.Errorf("crypt decoder: %w", err)
|
||||
}
|
||||
|
||||
digest, err := decoder.Decode(hash)
|
||||
if err != nil {
|
||||
logger.Debug("nativessh/pam: failed to decode hash for %q: %v", username, err)
|
||||
return fmt.Errorf("unsupported password hash scheme %q: %w", scheme, err)
|
||||
}
|
||||
|
||||
match, err := digest.MatchAdvanced(password)
|
||||
if err != nil {
|
||||
logger.Debug("nativessh/pam: MatchAdvanced for %q failed: %v", username, err)
|
||||
return err
|
||||
}
|
||||
if !match {
|
||||
logger.Debug("nativessh/pam: password mismatch for %q", username)
|
||||
return errors.New("authentication failed")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// readShadowHash reads /etc/shadow and returns the password hash for username.
|
||||
func readShadowHash(username string) (string, error) {
|
||||
f, err := os.Open("/etc/shadow")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
scanner := bufio.NewScanner(f)
|
||||
for scanner.Scan() {
|
||||
fields := strings.SplitN(scanner.Text(), ":", 3)
|
||||
if len(fields) < 2 || fields[0] != username {
|
||||
continue
|
||||
}
|
||||
h := fields[1]
|
||||
if h == "" || h == "*" || strings.HasPrefix(h, "!") || h == "x" {
|
||||
return "", errors.New("account locked or has no password")
|
||||
}
|
||||
return h, nil
|
||||
}
|
||||
if err := scanner.Err(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return "", errors.New("user not found in shadow database")
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
//go:build !linux
|
||||
|
||||
package nativessh
|
||||
|
||||
import "errors"
|
||||
|
||||
// VerifySystemPassword is not supported on non-Linux platforms; it always
|
||||
// returns an error so that password authentication is never accepted.
|
||||
func VerifySystemPassword(username, password string) error {
|
||||
return errors.New("password authentication not supported on this platform")
|
||||
}
|
||||
130
nativessh/pty.go
130
nativessh/pty.go
@@ -1,130 +0,0 @@
|
||||
//go:build !windows
|
||||
|
||||
package nativessh
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/user"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/creack/pty"
|
||||
)
|
||||
|
||||
// PTYSession is a running shell process attached to a PTY.
|
||||
// It implements io.ReadWriteCloser so it can be bridged to any transport.
|
||||
type PTYSession struct {
|
||||
ptmx *os.File
|
||||
cmd *exec.Cmd
|
||||
waitOnce sync.Once
|
||||
exitCode int
|
||||
}
|
||||
|
||||
// findShell returns the path to the best available interactive shell by
|
||||
// checking preferred shells in order, falling back to /bin/sh.
|
||||
func findShell() string {
|
||||
preferred := []string{"zsh", "bash", "fish", "ksh", "sh"}
|
||||
for _, name := range preferred {
|
||||
if path, err := exec.LookPath(name); err == nil {
|
||||
return path
|
||||
}
|
||||
}
|
||||
return "/bin/sh"
|
||||
}
|
||||
|
||||
// userShell returns the login shell configured for u in /etc/passwd.
|
||||
// If the field is empty or the binary does not exist, it falls back to
|
||||
// findShell so there is always a usable shell.
|
||||
func userShell(u *user.User) string {
|
||||
if shell := passwdShell(u.Username); shell != "" {
|
||||
if _, err := exec.LookPath(shell); err == nil {
|
||||
return shell
|
||||
}
|
||||
}
|
||||
return findShell()
|
||||
}
|
||||
|
||||
// passwdShell reads /etc/passwd and returns the login shell for the named user.
|
||||
// Returns "" if the user is not found or the file cannot be read.
|
||||
func passwdShell(username string) string {
|
||||
f, err := os.Open("/etc/passwd")
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
defer f.Close()
|
||||
scanner := bufio.NewScanner(f)
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
if line == "" || line[0] == '#' {
|
||||
continue
|
||||
}
|
||||
// Fields: username:password:uid:gid:gecos:home:shell
|
||||
fields := strings.SplitN(line, ":", 7)
|
||||
if len(fields) == 7 && fields[0] == username {
|
||||
return fields[6]
|
||||
}
|
||||
}
|
||||
_ = scanner.Err()
|
||||
return ""
|
||||
}
|
||||
|
||||
// NewPTYSession spawns the best available shell in a PTY.
|
||||
func NewPTYSession() (*PTYSession, error) {
|
||||
shell := findShell()
|
||||
cmd := exec.Command(shell)
|
||||
cmd.Env = append(os.Environ(), "TERM=xterm-256color")
|
||||
ptmx, err := pty.Start(cmd)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("pty start: %w", err)
|
||||
}
|
||||
return &PTYSession{ptmx: ptmx, cmd: cmd}, nil
|
||||
}
|
||||
|
||||
// Read reads output from the PTY.
|
||||
func (p *PTYSession) Read(b []byte) (int, error) {
|
||||
return p.ptmx.Read(b)
|
||||
}
|
||||
|
||||
// Write writes input to the PTY.
|
||||
func (p *PTYSession) Write(b []byte) (int, error) {
|
||||
return p.ptmx.Write(b)
|
||||
}
|
||||
|
||||
// Resize changes the PTY window size.
|
||||
func (p *PTYSession) Resize(cols, rows uint16) error {
|
||||
return pty.Setsize(p.ptmx, &pty.Winsize{Cols: cols, Rows: rows})
|
||||
}
|
||||
|
||||
// wait waits for the child process to exit exactly once and records its exit
|
||||
// code. Safe to call concurrently or multiple times.
|
||||
func (p *PTYSession) wait() {
|
||||
p.waitOnce.Do(func() {
|
||||
err := p.cmd.Wait()
|
||||
if err != nil {
|
||||
var exitErr *exec.ExitError
|
||||
if errors.As(err, &exitErr) {
|
||||
p.exitCode = exitErr.ExitCode()
|
||||
return
|
||||
}
|
||||
p.exitCode = 1
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ExitCode waits for the shell process to exit and returns its exit code.
|
||||
// It is safe to call before or after Close.
|
||||
func (p *PTYSession) ExitCode() int {
|
||||
p.wait()
|
||||
return p.exitCode
|
||||
}
|
||||
|
||||
// Close closes the PTY and waits for the child process to exit.
|
||||
func (p *PTYSession) Close() error {
|
||||
err := p.ptmx.Close()
|
||||
p.wait()
|
||||
return err
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
//go:build !windows
|
||||
|
||||
package nativessh
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/user"
|
||||
"strconv"
|
||||
"syscall"
|
||||
|
||||
"github.com/creack/pty"
|
||||
)
|
||||
|
||||
// NewPTYSessionAs spawns an interactive shell in a PTY running as the given
|
||||
// system user. The calling process must have sufficient privileges (typically
|
||||
// root / CAP_SETUID) to switch to a different UID/GID.
|
||||
func NewPTYSessionAs(username string) (*PTYSession, error) {
|
||||
u, err := user.Lookup(username)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("user lookup %q: %w", username, err)
|
||||
}
|
||||
uid, err := strconv.ParseUint(u.Uid, 10, 32)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse uid: %w", err)
|
||||
}
|
||||
gid, err := strconv.ParseUint(u.Gid, 10, 32)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse gid: %w", err)
|
||||
}
|
||||
|
||||
// Collect supplementary group IDs.
|
||||
groupIDs, err := u.GroupIds()
|
||||
if err != nil {
|
||||
groupIDs = []string{}
|
||||
}
|
||||
var groups []uint32
|
||||
for _, g := range groupIDs {
|
||||
gval, err := strconv.ParseUint(g, 10, 32)
|
||||
if err == nil {
|
||||
groups = append(groups, uint32(gval))
|
||||
}
|
||||
}
|
||||
|
||||
shell := userShell(u)
|
||||
|
||||
// Prefer the user's home directory as the working directory, but fall back
|
||||
// to / if it does not exist (e.g. useradd was run without -m).
|
||||
homeDir := u.HomeDir
|
||||
if _, err := os.Stat(homeDir); err != nil {
|
||||
homeDir = "/"
|
||||
}
|
||||
|
||||
cmd := exec.Command(shell, "--login")
|
||||
cmd.Env = []string{
|
||||
"TERM=xterm-256color",
|
||||
"HOME=" + u.HomeDir,
|
||||
"USER=" + username,
|
||||
"LOGNAME=" + username,
|
||||
"SHELL=" + shell,
|
||||
"PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
|
||||
}
|
||||
cmd.Dir = homeDir
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{
|
||||
Credential: &syscall.Credential{
|
||||
Uid: uint32(uid),
|
||||
Gid: uint32(gid),
|
||||
Groups: groups,
|
||||
},
|
||||
}
|
||||
|
||||
ptmx, err := pty.Start(cmd)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("pty start: %w", err)
|
||||
}
|
||||
return &PTYSession{ptmx: ptmx, cmd: cmd}, nil
|
||||
}
|
||||
@@ -1,467 +0,0 @@
|
||||
//go:build !windows
|
||||
|
||||
package nativessh
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/user"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
|
||||
"github.com/fosrl/newt/logger"
|
||||
"golang.org/x/crypto/ssh"
|
||||
)
|
||||
|
||||
// CredentialStore holds in-memory SSH credentials that can be updated at runtime.
|
||||
// It is safe for concurrent use.
|
||||
type CredentialStore struct {
|
||||
mu sync.RWMutex
|
||||
caKey ssh.PublicKey
|
||||
principals map[string]map[string]struct{} // username -> set of allowed principals
|
||||
}
|
||||
|
||||
// connMetaWithUser wraps ConnMetadata while overriding User() for cert checks.
|
||||
type connMetaWithUser struct {
|
||||
ssh.ConnMetadata
|
||||
user string
|
||||
}
|
||||
|
||||
func (m connMetaWithUser) User() string { return m.user }
|
||||
|
||||
// NewCredentialStore returns an empty, ready-to-use CredentialStore.
|
||||
func NewCredentialStore() *CredentialStore {
|
||||
return &CredentialStore{
|
||||
principals: make(map[string]map[string]struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
// SetCAKey parses and stores the CA public key from authorized_keys-format data.
|
||||
func (s *CredentialStore) SetCAKey(authorizedKeyData string) error {
|
||||
key, _, _, _, err := ssh.ParseAuthorizedKey([]byte(authorizedKeyData))
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse CA key: %w", err)
|
||||
}
|
||||
s.mu.Lock()
|
||||
s.caKey = key
|
||||
s.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
// AddPrincipals records username and niceId as allowed principals for username.
|
||||
// Both values are stored; either can appear in the certificate's ValidPrincipals
|
||||
// field to satisfy the standard cert-auth principal check.
|
||||
func (s *CredentialStore) AddPrincipals(username, niceId string) {
|
||||
username = strings.TrimSpace(username)
|
||||
niceId = strings.TrimSpace(niceId)
|
||||
if username == "" {
|
||||
return
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.principals[username] == nil {
|
||||
s.principals[username] = make(map[string]struct{})
|
||||
}
|
||||
s.principals[username][username] = struct{}{}
|
||||
if niceId != "" {
|
||||
s.principals[username][niceId] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
// get returns the CA key and the principal set for username under a read lock.
|
||||
func (s *CredentialStore) get(username string) (ssh.PublicKey, map[string]struct{}) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return s.caKey, s.principals[username]
|
||||
}
|
||||
|
||||
// ServerConfig holds configuration for the native SSH server.
|
||||
type ServerConfig struct {
|
||||
// ListenAddr is the TCP address to listen on. Defaults to ":2222".
|
||||
ListenAddr string
|
||||
// Credentials provides in-memory CA key and per-user principals.
|
||||
// Updates to the store are reflected immediately for new connections.
|
||||
// If nil or the store has no CA key set, all connections are rejected.
|
||||
Credentials *CredentialStore
|
||||
}
|
||||
|
||||
// Server is a simple SSH server that authenticates clients via SSH certificate
|
||||
// auth only. Certificates must be signed by the configured CA and the
|
||||
// connecting username must appear in both the certificate's principal list and
|
||||
// the local principals file.
|
||||
type Server struct {
|
||||
cfg ServerConfig
|
||||
}
|
||||
|
||||
// NewServer creates a new Server. The ListenAddr defaults to ":2222" when empty.
|
||||
func NewServer(cfg ServerConfig) *Server {
|
||||
if cfg.ListenAddr == "" {
|
||||
cfg.ListenAddr = ":2222"
|
||||
}
|
||||
return &Server{cfg: cfg}
|
||||
}
|
||||
|
||||
// buildSSHConfig builds the ssh.ServerConfig with multi-method authentication:
|
||||
// 1. Public key: host ~/.ssh/authorized_keys, then CA certificate.
|
||||
// 2. Password: system PAM stack (Linux only).
|
||||
func (s *Server) buildSSHConfig() (*ssh.ServerConfig, error) {
|
||||
hostSigner, err := generateHostKey()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("host key: %w", err)
|
||||
}
|
||||
cfg := &ssh.ServerConfig{
|
||||
PublicKeyCallback: makePublicKeyCallback(s.cfg.Credentials),
|
||||
PasswordCallback: makePasswordCallback(),
|
||||
}
|
||||
cfg.AddHostKey(hostSigner)
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// Serve accepts connections on ln and handles them. It returns when ln is
|
||||
// closed or a non-temporary Accept error occurs.
|
||||
func (s *Server) Serve(ln net.Listener) error {
|
||||
sshCfg, err := s.buildSSHConfig()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
logger.Debug("nativessh: server listening on %s", ln.Addr())
|
||||
for {
|
||||
conn, err := ln.Accept()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
go s.handleConn(conn, sshCfg)
|
||||
}
|
||||
}
|
||||
|
||||
// ListenAndServe starts the SSH server on the host network and blocks until
|
||||
// the listener is closed.
|
||||
func (s *Server) ListenAndServe() error {
|
||||
ln, err := net.Listen("tcp", s.cfg.ListenAddr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("listen %s: %w", s.cfg.ListenAddr, err)
|
||||
}
|
||||
defer ln.Close()
|
||||
return s.Serve(ln)
|
||||
}
|
||||
|
||||
func (s *Server) handleConn(conn net.Conn, cfg *ssh.ServerConfig) {
|
||||
defer conn.Close()
|
||||
sshConn, chans, reqs, err := ssh.NewServerConn(conn, cfg)
|
||||
if err != nil {
|
||||
logger.Debug("nativessh: handshake failed from %s: %v", conn.RemoteAddr(), err)
|
||||
return
|
||||
}
|
||||
defer sshConn.Close()
|
||||
logger.Debug("nativessh: connection from %s user=%s", conn.RemoteAddr(), sshConn.User())
|
||||
|
||||
go ssh.DiscardRequests(reqs)
|
||||
|
||||
for newChan := range chans {
|
||||
if newChan.ChannelType() != "session" {
|
||||
_ = newChan.Reject(ssh.UnknownChannelType, "unknown channel type")
|
||||
continue
|
||||
}
|
||||
ch, requests, err := newChan.Accept()
|
||||
if err != nil {
|
||||
logger.Debug("nativessh: channel accept error: %v", err)
|
||||
return
|
||||
}
|
||||
go s.handleSession(ch, requests, sshConn.User())
|
||||
}
|
||||
}
|
||||
|
||||
// handleSession drives a single SSH session channel. It waits for a pty-req
|
||||
// followed by a shell request and then bridges the PTY to the channel.
|
||||
func (s *Server) handleSession(ch ssh.Channel, requests <-chan *ssh.Request, username string) {
|
||||
defer ch.Close()
|
||||
|
||||
var (
|
||||
sess *PTYSession
|
||||
started bool
|
||||
)
|
||||
|
||||
for req := range requests {
|
||||
switch req.Type {
|
||||
case "pty-req":
|
||||
var err error
|
||||
if sess == nil {
|
||||
sess, err = NewPTYSessionAs(username)
|
||||
if err != nil {
|
||||
logger.Debug("nativessh: PTY start error: %v", err)
|
||||
if req.WantReply {
|
||||
_ = req.Reply(false, nil)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
cols, rows := parsePTYReq(req.Payload)
|
||||
_ = sess.Resize(cols, rows)
|
||||
if req.WantReply {
|
||||
_ = req.Reply(true, nil)
|
||||
}
|
||||
|
||||
case "shell":
|
||||
if req.WantReply {
|
||||
_ = req.Reply(true, nil)
|
||||
}
|
||||
if started || sess == nil {
|
||||
continue
|
||||
}
|
||||
started = true
|
||||
// PTY output → SSH channel.
|
||||
go func() {
|
||||
_, _ = io.Copy(ch, sess)
|
||||
// Notify the client of the shell's exit status so it can
|
||||
// disconnect cleanly instead of requiring a manual disconnect.
|
||||
exitCode := sess.ExitCode()
|
||||
exitStatusPayload := ssh.Marshal(struct{ Status uint32 }{uint32(exitCode)})
|
||||
_, _ = ch.SendRequest("exit-status", false, exitStatusPayload)
|
||||
_ = ch.CloseWrite()
|
||||
sess.Close() //nolint:errcheck
|
||||
// Close the channel so the ssh library closes the requests
|
||||
// channel, which unblocks the for-range loop in handleSession
|
||||
// and allows the deferred ch.Close() to run. Without this,
|
||||
// handleSession blocks forever waiting for requests to drain.
|
||||
_ = ch.Close()
|
||||
}()
|
||||
// SSH channel input → PTY stdin.
|
||||
go func() {
|
||||
_, _ = io.Copy(sess, ch)
|
||||
}()
|
||||
|
||||
case "exec":
|
||||
if req.WantReply {
|
||||
_ = req.Reply(true, nil)
|
||||
}
|
||||
cmd := parseExecPayload(req.Payload)
|
||||
go s.runExecAs(ch, username, cmd)
|
||||
// Drain remaining requests; the goroutine owns the channel now.
|
||||
go ssh.DiscardRequests(requests)
|
||||
return
|
||||
|
||||
case "window-change":
|
||||
if sess != nil {
|
||||
cols, rows := parseWindowChange(req.Payload)
|
||||
_ = sess.Resize(cols, rows)
|
||||
}
|
||||
if req.WantReply {
|
||||
_ = req.Reply(true, nil)
|
||||
}
|
||||
|
||||
default:
|
||||
if req.WantReply {
|
||||
_ = req.Reply(false, nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if sess != nil && !started {
|
||||
sess.Close() //nolint:errcheck
|
||||
}
|
||||
}
|
||||
|
||||
// runExecAs runs command as username, wiring stdin/stdout/stderr directly to
|
||||
// the SSH channel (no PTY). This supports scp, rsync, and plain exec requests.
|
||||
func (s *Server) runExecAs(ch ssh.Channel, username, command string) {
|
||||
defer ch.Close()
|
||||
|
||||
u, err := user.Lookup(username)
|
||||
if err != nil {
|
||||
logger.Debug("nativessh: exec user lookup %q: %v", username, err)
|
||||
sendExitStatus(ch, 1)
|
||||
return
|
||||
}
|
||||
|
||||
uid, err := strconv.ParseUint(u.Uid, 10, 32)
|
||||
if err != nil {
|
||||
logger.Debug("nativessh: exec parse uid for %q: %v", username, err)
|
||||
sendExitStatus(ch, 1)
|
||||
return
|
||||
}
|
||||
gid, err := strconv.ParseUint(u.Gid, 10, 32)
|
||||
if err != nil {
|
||||
logger.Debug("nativessh: exec parse gid for %q: %v", username, err)
|
||||
sendExitStatus(ch, 1)
|
||||
return
|
||||
}
|
||||
|
||||
groupIDs, _ := u.GroupIds()
|
||||
var groups []uint32
|
||||
for _, g := range groupIDs {
|
||||
gval, err := strconv.ParseUint(g, 10, 32)
|
||||
if err == nil {
|
||||
groups = append(groups, uint32(gval))
|
||||
}
|
||||
}
|
||||
|
||||
homeDir := u.HomeDir
|
||||
if _, err := os.Stat(homeDir); err != nil {
|
||||
homeDir = "/"
|
||||
}
|
||||
|
||||
cmd := exec.Command("/bin/sh", "-c", command)
|
||||
cmd.Env = []string{
|
||||
"HOME=" + u.HomeDir,
|
||||
"USER=" + username,
|
||||
"LOGNAME=" + username,
|
||||
"PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
|
||||
}
|
||||
cmd.Dir = homeDir
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{
|
||||
Credential: &syscall.Credential{
|
||||
Uid: uint32(uid),
|
||||
Gid: uint32(gid),
|
||||
Groups: groups,
|
||||
},
|
||||
}
|
||||
cmd.Stdin = ch
|
||||
cmd.Stdout = ch
|
||||
cmd.Stderr = ch.Stderr()
|
||||
|
||||
exitCode := 0
|
||||
if err := cmd.Run(); err != nil {
|
||||
var exitErr *exec.ExitError
|
||||
if errors.As(err, &exitErr) {
|
||||
exitCode = exitErr.ExitCode()
|
||||
} else {
|
||||
exitCode = 1
|
||||
}
|
||||
logger.Debug("nativessh: exec %q as %q exited: %v", command, username, err)
|
||||
}
|
||||
|
||||
sendExitStatus(ch, exitCode)
|
||||
_ = ch.CloseWrite()
|
||||
}
|
||||
|
||||
// sendExitStatus sends an SSH exit-status request on ch.
|
||||
func sendExitStatus(ch ssh.Channel, code int) {
|
||||
payload := ssh.Marshal(struct{ Status uint32 }{uint32(code)})
|
||||
_, _ = ch.SendRequest("exit-status", false, payload)
|
||||
}
|
||||
|
||||
// parseExecPayload decodes the command string from an SSH exec request payload.
|
||||
func parseExecPayload(payload []byte) string {
|
||||
var msg struct{ Command string }
|
||||
if err := ssh.Unmarshal(payload, &msg); err != nil {
|
||||
return ""
|
||||
}
|
||||
return msg.Command
|
||||
}
|
||||
|
||||
// makePublicKeyCallback returns a PublicKeyCallback that tries, in order:
|
||||
// 1. Host authorized_keys – matches any key in the OS user's
|
||||
// ~/.ssh/authorized_keys file.
|
||||
// 2. CA certificate – validates an SSH certificate signed by the
|
||||
// configured CA and checks that the user appears in the principals map.
|
||||
//
|
||||
// store may be nil or empty; those paths are simply skipped.
|
||||
func makePublicKeyCallback(store *CredentialStore) func(ssh.ConnMetadata, ssh.PublicKey) (*ssh.Permissions, error) {
|
||||
return func(meta ssh.ConnMetadata, key ssh.PublicKey) (*ssh.Permissions, error) {
|
||||
// 1. Host authorized_keys.
|
||||
if CheckAuthorizedKeys(meta.User(), key) {
|
||||
logger.Debug("nativessh: authorized_keys auth for user %q", meta.User())
|
||||
return &ssh.Permissions{}, nil
|
||||
}
|
||||
|
||||
// 2. CA certificate.
|
||||
if store != nil {
|
||||
caKey, userPrincipals := store.get(meta.User())
|
||||
if caKey != nil {
|
||||
checker := &ssh.CertChecker{
|
||||
IsUserAuthority: func(auth ssh.PublicKey) bool {
|
||||
return ssh.FingerprintSHA256(auth) == ssh.FingerprintSHA256(caKey)
|
||||
},
|
||||
}
|
||||
|
||||
if len(userPrincipals) == 0 {
|
||||
return nil, fmt.Errorf("user %q not in allowed principals list", meta.User())
|
||||
}
|
||||
|
||||
var lastErr error
|
||||
for principal := range userPrincipals {
|
||||
perms, err := checker.Authenticate(connMetaWithUser{ConnMetadata: meta, user: principal}, key)
|
||||
if err == nil {
|
||||
logger.Debug("nativessh: CA cert auth for user %q principal=%q", meta.User(), principal)
|
||||
return perms, nil
|
||||
}
|
||||
lastErr = err
|
||||
}
|
||||
|
||||
if lastErr != nil {
|
||||
logger.Debug("nativessh: CA cert rejected for user %q: %v", meta.User(), lastErr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("public key not authorized for user %q", meta.User())
|
||||
}
|
||||
}
|
||||
|
||||
// makePasswordCallback returns a PasswordCallback that validates the supplied
|
||||
// password via the host OS PAM stack. On non-Linux platforms this always
|
||||
// fails (see pam_other.go).
|
||||
func makePasswordCallback() func(ssh.ConnMetadata, []byte) (*ssh.Permissions, error) {
|
||||
return func(meta ssh.ConnMetadata, password []byte) (*ssh.Permissions, error) {
|
||||
if err := VerifySystemPassword(meta.User(), string(password)); err != nil {
|
||||
// Return a generic message to the client; log the real reason.
|
||||
logger.Debug("nativessh: password auth failed for user %q: %v", meta.User(), err)
|
||||
return nil, fmt.Errorf("permission denied")
|
||||
}
|
||||
logger.Debug("nativessh: password auth for user %q", meta.User())
|
||||
return &ssh.Permissions{}, nil
|
||||
}
|
||||
}
|
||||
|
||||
// generateHostKey generates a fresh ephemeral Ed25519 host key in memory.
|
||||
// A new key is created on every server start; nothing is written to disk.
|
||||
func generateHostKey() (ssh.Signer, error) {
|
||||
_, priv, err := ed25519.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("generate host key: %w", err)
|
||||
}
|
||||
logger.Debug("nativessh: generated ephemeral Ed25519 host key")
|
||||
return ssh.NewSignerFromKey(priv)
|
||||
}
|
||||
|
||||
// ptyRequestMsg mirrors the SSH wire format for pty-req (RFC 4254 §6.2).
|
||||
type ptyRequestMsg struct {
|
||||
Term string
|
||||
Columns uint32
|
||||
Rows uint32
|
||||
Width uint32
|
||||
Height uint32
|
||||
Modelist string
|
||||
}
|
||||
|
||||
func parsePTYReq(payload []byte) (cols, rows uint16) {
|
||||
var req ptyRequestMsg
|
||||
if err := ssh.Unmarshal(payload, &req); err != nil {
|
||||
return 80, 24
|
||||
}
|
||||
return uint16(req.Columns), uint16(req.Rows)
|
||||
}
|
||||
|
||||
// windowChangeMsg mirrors the SSH wire format for window-change (RFC 4254 §6.7).
|
||||
type windowChangeMsg struct {
|
||||
Columns uint32
|
||||
Rows uint32
|
||||
Width uint32
|
||||
Height uint32
|
||||
}
|
||||
|
||||
func parseWindowChange(payload []byte) (cols, rows uint16) {
|
||||
var msg windowChangeMsg
|
||||
if err := ssh.Unmarshal(payload, &msg); err != nil {
|
||||
return 80, 24
|
||||
}
|
||||
return uint16(msg.Columns), uint16(msg.Rows)
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
//go:build windows
|
||||
|
||||
package nativessh
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log"
|
||||
"net"
|
||||
"sync"
|
||||
|
||||
"golang.org/x/crypto/ssh"
|
||||
)
|
||||
|
||||
// CredentialStore is a stub on Windows. Native SSH is not supported on Windows.
|
||||
type CredentialStore struct {
|
||||
mu sync.RWMutex
|
||||
principals map[string]map[string]struct{}
|
||||
}
|
||||
|
||||
// NewCredentialStore returns an empty CredentialStore stub.
|
||||
// Native SSH is not supported on Windows; a warning is logged.
|
||||
func NewCredentialStore() *CredentialStore {
|
||||
log.Println("WARNING: native SSH is not supported on Windows and will be disabled")
|
||||
return &CredentialStore{
|
||||
principals: make(map[string]map[string]struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
// SetCAKey is a no-op stub on Windows.
|
||||
func (s *CredentialStore) SetCAKey(_ string) error {
|
||||
return errors.New("native SSH not supported on Windows")
|
||||
}
|
||||
|
||||
// AddPrincipals is a no-op stub on Windows.
|
||||
func (s *CredentialStore) AddPrincipals(_, _ string) {}
|
||||
|
||||
// get returns nil CA key and empty principals on Windows.
|
||||
func (s *CredentialStore) get(_ string) (ssh.PublicKey, map[string]struct{}) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// ServerConfig holds configuration for the native SSH server (stub on Windows).
|
||||
type ServerConfig struct {
|
||||
ListenAddr string
|
||||
Credentials *CredentialStore
|
||||
}
|
||||
|
||||
// Server is a stub on Windows.
|
||||
type Server struct{}
|
||||
|
||||
// NewServer returns a stub Server and logs a warning.
|
||||
func NewServer(cfg ServerConfig) *Server {
|
||||
return &Server{}
|
||||
}
|
||||
|
||||
// ListenAndServe always returns an error on Windows.
|
||||
func (s *Server) ListenAndServe() error {
|
||||
return errors.New("native SSH not supported on Windows")
|
||||
}
|
||||
|
||||
// Serve always returns an error on Windows.
|
||||
func (s *Server) Serve(_ net.Listener) error {
|
||||
return errors.New("native SSH not supported on Windows")
|
||||
}
|
||||
@@ -1,3 +1,8 @@
|
||||
/* SPDX-License-Identifier: MIT
|
||||
*
|
||||
* Copyright (C) 2017-2025 WireGuard LLC. All Rights Reserved.
|
||||
*/
|
||||
|
||||
package netstack2
|
||||
|
||||
import (
|
||||
@@ -139,13 +144,6 @@ func (h *TCPHandler) handleTCPConn(netstackConn *gonet.TCPConn, id stack.Transpo
|
||||
dstIP := id.LocalAddress.String()
|
||||
dstPort := id.LocalPort
|
||||
|
||||
// Drop connection if blocking is enabled
|
||||
if h.proxyHandler != nil && h.proxyHandler.IsBlocked() {
|
||||
logger.Debug("TCP Forwarder: connection blocked: %s:%d -> %s:%d", srcIP, srcPort, dstIP, dstPort)
|
||||
netstackConn.Close()
|
||||
return
|
||||
}
|
||||
|
||||
// For HTTP/HTTPS ports, look up the matching subnet rule. If the rule has
|
||||
// Protocol configured, hand the connection off to the HTTP handler which
|
||||
// takes full ownership of the lifecycle (the defer close must not be
|
||||
@@ -166,13 +164,6 @@ func (h *TCPHandler) handleTCPConn(netstackConn *gonet.TCPConn, id stack.Transpo
|
||||
|
||||
defer netstackConn.Close()
|
||||
|
||||
// Release this connection's NAT state once it fully closes, so a rule
|
||||
// change (e.g. RewriteTo) takes effect for the next connection on this
|
||||
// tuple instead of being masked by a stale cached resolution forever.
|
||||
if h.proxyHandler != nil {
|
||||
defer h.proxyHandler.releaseConnectionState(srcIP, srcPort, dstIP, dstPort, uint8(tcp.ProtocolNumber))
|
||||
}
|
||||
|
||||
logger.Info("TCP Forwarder: Handling connection %s:%d -> %s:%d", srcIP, srcPort, dstIP, dstPort)
|
||||
|
||||
// Check if there's a destination rewrite for this connection (e.g., localhost targets)
|
||||
@@ -322,21 +313,8 @@ func (h *UDPHandler) handleUDPConn(netstackConn *gonet.UDPConn, id stack.Transpo
|
||||
dstIP := id.LocalAddress.String()
|
||||
dstPort := id.LocalPort
|
||||
|
||||
// Release this session's NAT state once it fully closes (session end or
|
||||
// idle timeout), so a rule change takes effect for the next session on
|
||||
// this tuple instead of being masked by a stale cached resolution.
|
||||
if h.proxyHandler != nil {
|
||||
defer h.proxyHandler.releaseConnectionState(srcIP, srcPort, dstIP, dstPort, uint8(udp.ProtocolNumber))
|
||||
}
|
||||
|
||||
logger.Info("UDP Forwarder: Handling connection %s:%d -> %s:%d", srcIP, srcPort, dstIP, dstPort)
|
||||
|
||||
// Drop connection if blocking is enabled
|
||||
if h.proxyHandler != nil && h.proxyHandler.IsBlocked() {
|
||||
logger.Debug("UDP Forwarder: connection blocked: %s:%d -> %s:%d", srcIP, srcPort, dstIP, dstPort)
|
||||
return
|
||||
}
|
||||
|
||||
// Check if there's a destination rewrite for this connection (e.g., localhost targets)
|
||||
actualDstIP := dstIP
|
||||
if h.proxyHandler != nil {
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
/* SPDX-License-Identifier: MIT
|
||||
*
|
||||
* Copyright (C) 2017-2025 WireGuard LLC. All Rights Reserved.
|
||||
*/
|
||||
|
||||
package netstack2
|
||||
|
||||
import (
|
||||
@@ -310,13 +315,6 @@ func (h *HTTPHandler) getProxy(target HTTPTarget) *httputil.ReverseProxy {
|
||||
// Director means the proxy does not append its own automatic
|
||||
// X-Forwarded-For entry, so the header is set exactly once.
|
||||
pr.SetXForwarded()
|
||||
|
||||
// SetXForwarded derives X-Forwarded-Proto from pr.In.TLS,
|
||||
// which is nil because httpConnCtx wraps *tls.Conn behind
|
||||
// net.Conn. Override using the context flag set by ConnContext.
|
||||
if isTLS, _ := pr.In.Context().Value(connTLSKey{}).(bool); isTLS {
|
||||
pr.Out.Header.Set("X-Forwarded-Proto", "https")
|
||||
}
|
||||
},
|
||||
Transport: transport,
|
||||
}
|
||||
|
||||
48
netstack2/http_handler_tls_test.go
Normal file
48
netstack2/http_handler_tls_test.go
Normal file
@@ -0,0 +1,48 @@
|
||||
package netstack2
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"net"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// tlsConnStub is a minimal net.Conn that also exposes TLS state, matching
|
||||
// *tls.Conn's ConnectionState used by net/http.Server.
|
||||
type tlsConnStub struct {
|
||||
net.Conn
|
||||
state tls.ConnectionState
|
||||
}
|
||||
|
||||
func (t *tlsConnStub) ConnectionState() tls.ConnectionState {
|
||||
return t.state
|
||||
}
|
||||
|
||||
func TestHTTPConnCtxForwardsConnectionState(t *testing.T) {
|
||||
c1, c2 := net.Pipe()
|
||||
defer c1.Close()
|
||||
defer c2.Close()
|
||||
|
||||
inner := &tlsConnStub{
|
||||
Conn: c1,
|
||||
state: tls.ConnectionState{Version: tls.VersionTLS12, HandshakeComplete: true},
|
||||
}
|
||||
wrapped := &httpConnCtx{Conn: inner, rule: nil}
|
||||
|
||||
got := wrapped.ConnectionState()
|
||||
if got.Version != tls.VersionTLS12 || !got.HandshakeComplete {
|
||||
t.Fatalf("ConnectionState = %+v, want TLS 1.2 and HandshakeComplete", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPConnCtxConnectionStatePlainTCP(t *testing.T) {
|
||||
c1, c2 := net.Pipe()
|
||||
defer c1.Close()
|
||||
defer c2.Close()
|
||||
|
||||
wrapped := &httpConnCtx{Conn: c1, rule: nil}
|
||||
got := wrapped.ConnectionState()
|
||||
if got.Version != 0 {
|
||||
t.Fatalf("expected zero ConnectionState for plain conn, got %+v", got)
|
||||
}
|
||||
_ = c2
|
||||
}
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"net"
|
||||
"net/netip"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/fosrl/newt/logger"
|
||||
@@ -135,7 +134,6 @@ type ProxyHandler struct {
|
||||
notifiable channel.Notification // Notification handler for triggering reads
|
||||
accessLogger *AccessLogger // Access logger for tracking sessions
|
||||
httpRequestLogger *HTTPRequestLogger // HTTP request logger for proxied HTTP/HTTPS requests
|
||||
blocked atomic.Bool // when true, all new connections are dropped
|
||||
}
|
||||
|
||||
// ProxyHandlerOptions configures the proxy handler
|
||||
@@ -242,28 +240,6 @@ func (p *ProxyHandler) AddSubnetRule(rule SubnetRule) {
|
||||
p.subnetLookup.AddSubnet(rule)
|
||||
}
|
||||
|
||||
// SetBlocked enables or disables connection blocking on this proxy handler.
|
||||
// When enabled, all new TCP/UDP connections from the tunnel are dropped immediately.
|
||||
func (p *ProxyHandler) SetBlocked(v bool) {
|
||||
if p == nil {
|
||||
return
|
||||
}
|
||||
p.blocked.Store(v)
|
||||
if v {
|
||||
logger.Debug("ProxyHandler: connection blocking enabled")
|
||||
} else {
|
||||
logger.Debug("ProxyHandler: connection blocking disabled")
|
||||
}
|
||||
}
|
||||
|
||||
// IsBlocked returns true if connection blocking is currently enabled.
|
||||
func (p *ProxyHandler) IsBlocked() bool {
|
||||
if p == nil {
|
||||
return false
|
||||
}
|
||||
return p.blocked.Load()
|
||||
}
|
||||
|
||||
// RemoveSubnetRule removes a subnet from the proxy handler
|
||||
func (p *ProxyHandler) RemoveSubnetRule(sourcePrefix, destPrefix netip.Prefix) {
|
||||
if p == nil || !p.enabled {
|
||||
@@ -272,18 +248,6 @@ func (p *ProxyHandler) RemoveSubnetRule(sourcePrefix, destPrefix netip.Prefix) {
|
||||
p.subnetLookup.RemoveSubnet(sourcePrefix, destPrefix)
|
||||
}
|
||||
|
||||
// ReplaceAllSubnetRules atomically replaces the full set of subnet rules.
|
||||
// Intended for full-state syncs where the desired rule set is authoritative,
|
||||
// so any stale rule is guaranteed to be cleared even if its key
|
||||
// (SourcePrefix, DestPrefix) matches a still-desired rule but its contents
|
||||
// (e.g. RewriteTo) have changed.
|
||||
func (p *ProxyHandler) ReplaceAllSubnetRules(rules []SubnetRule) {
|
||||
if p == nil || !p.enabled {
|
||||
return
|
||||
}
|
||||
p.subnetLookup.ReplaceAll(rules)
|
||||
}
|
||||
|
||||
// GetAllRules returns all subnet rules from the proxy handler
|
||||
func (p *ProxyHandler) GetAllRules() []SubnetRule {
|
||||
if p == nil || !p.enabled {
|
||||
@@ -347,51 +311,6 @@ func (p *ProxyHandler) SetHTTPRequestLogSender(fn SendFunc) {
|
||||
p.httpRequestLogger.SetSendFunc(fn)
|
||||
}
|
||||
|
||||
// releaseConnectionState removes the per-connection NAT state for a single
|
||||
// (srcIP, srcPort, dstIP, dstPort, proto) tuple. Callers must only invoke
|
||||
// this once that exact connection has fully closed (both directions torn
|
||||
// down), since a new connection can never be accepted on the same 5-tuple
|
||||
// before then.
|
||||
//
|
||||
// This intentionally does NOT touch destRewriteTable/resourceTable: those
|
||||
// are keyed without srcPort (destKey), so they are shared across every
|
||||
// concurrent connection from the same source to the same destination
|
||||
// service. They don't need connection-scoped cleanup - each new connection's
|
||||
// first packet already refreshes them via HandleIncomingPacket - and
|
||||
// deleting them here on a single connection's close could break other
|
||||
// connections still in flight to the same destination.
|
||||
func (p *ProxyHandler) releaseConnectionState(srcIP string, srcPort uint16, dstIP string, dstPort uint16, proto uint8) {
|
||||
if p == nil || !p.enabled {
|
||||
return
|
||||
}
|
||||
|
||||
key := connKey{
|
||||
srcIP: srcIP,
|
||||
srcPort: srcPort,
|
||||
dstIP: dstIP,
|
||||
dstPort: dstPort,
|
||||
proto: proto,
|
||||
}
|
||||
|
||||
p.natMu.Lock()
|
||||
defer p.natMu.Unlock()
|
||||
|
||||
entry, ok := p.natTable[key]
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
delete(p.natTable, key)
|
||||
|
||||
reverseKey := reverseConnKey{
|
||||
rewrittenTo: entry.rewrittenTo.String(),
|
||||
originalSrcIP: srcIP,
|
||||
originalSrcPort: srcPort,
|
||||
originalDstPort: dstPort,
|
||||
proto: proto,
|
||||
}
|
||||
delete(p.reverseNatTable, reverseKey)
|
||||
}
|
||||
|
||||
// LookupDestinationRewrite looks up the rewritten destination for a connection
|
||||
// This is used by TCP/UDP handlers to find the actual target address
|
||||
func (p *ProxyHandler) LookupDestinationRewrite(srcIP, dstIP string, dstPort uint16, proto uint8) (netip.Addr, bool) {
|
||||
@@ -693,7 +612,7 @@ func (p *ProxyHandler) HandleIncomingPacket(packet []byte) bool {
|
||||
}
|
||||
|
||||
// logger.Debug("HandleIncomingPacket: No matching rule for %s -> %s (proto=%d, port=%d)",
|
||||
// srcAddr, dstAddr, protocol, dstPort)
|
||||
// srcAddr, dstAddr, protocol, dstPort)
|
||||
return false
|
||||
}
|
||||
|
||||
|
||||
@@ -52,13 +52,6 @@ func (sl *SubnetLookup) AddSubnet(rule SubnetRule) {
|
||||
sl.mu.Lock()
|
||||
defer sl.mu.Unlock()
|
||||
|
||||
sl.addSubnetLocked(rule)
|
||||
}
|
||||
|
||||
// addSubnetLocked is the lock-free body of AddSubnet, factored out so
|
||||
// ReplaceAll can insert many rules under a single lock acquisition.
|
||||
// Callers must hold sl.mu for writing.
|
||||
func (sl *SubnetLookup) addSubnetLocked(rule SubnetRule) {
|
||||
rulePtr := &rule
|
||||
|
||||
// Canonicalize source prefix to handle host bits correctly
|
||||
@@ -96,21 +89,6 @@ func (sl *SubnetLookup) addSubnetLocked(rule SubnetRule) {
|
||||
destTriePtr.rules = newRules
|
||||
}
|
||||
|
||||
// ReplaceAll atomically replaces the entire rule set with the given rules.
|
||||
// This guarantees no stale rule can survive a sync, even when a rule's key
|
||||
// (SourcePrefix, DestPrefix) is unchanged but other fields (e.g. RewriteTo)
|
||||
// differ - a case that an add/remove diff keyed only on prefixes would miss.
|
||||
func (sl *SubnetLookup) ReplaceAll(rules []SubnetRule) {
|
||||
sl.mu.Lock()
|
||||
defer sl.mu.Unlock()
|
||||
|
||||
sl.sourceTrie = &bart.Table[*destTrie]{}
|
||||
|
||||
for _, rule := range rules {
|
||||
sl.addSubnetLocked(rule)
|
||||
}
|
||||
}
|
||||
|
||||
// RemoveSubnet removes a subnet rule from the lookup table
|
||||
func (sl *SubnetLookup) RemoveSubnet(sourcePrefix, destPrefix netip.Prefix) {
|
||||
sl.mu.Lock()
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
/* SPDX-License-Identifier: MIT
|
||||
*
|
||||
* Copyright (C) 2017-2025 WireGuard LLC. All Rights Reserved.
|
||||
*/
|
||||
|
||||
package netstack2
|
||||
|
||||
import (
|
||||
@@ -364,15 +369,6 @@ func (net *Net) RemoveProxySubnetRule(sourcePrefix, destPrefix netip.Prefix) {
|
||||
}
|
||||
}
|
||||
|
||||
// ReplaceProxySubnetRules atomically replaces the full set of subnet rules
|
||||
// on the proxy handler with the given rules.
|
||||
func (net *Net) ReplaceProxySubnetRules(rules []SubnetRule) {
|
||||
tun := (*netTun)(net)
|
||||
if tun.proxyHandler != nil {
|
||||
tun.proxyHandler.ReplaceAllSubnetRules(rules)
|
||||
}
|
||||
}
|
||||
|
||||
// GetProxySubnetRules returns all subnet rules from the proxy handler
|
||||
func (net *Net) GetProxySubnetRules() []SubnetRule {
|
||||
tun := (*netTun)(net)
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
package newt
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"runtime"
|
||||
|
||||
"github.com/fosrl/newt/authdaemon"
|
||||
"github.com/fosrl/newt/logger"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultPrincipalsPath = "/var/run/auth-daemon/principals"
|
||||
defaultCACertPath = "/etc/ssh/ca.pem"
|
||||
)
|
||||
|
||||
func (n *Newt) startAuthDaemon(ctx context.Context) error {
|
||||
if runtime.GOOS != "linux" {
|
||||
return fmt.Errorf("auth-daemon is only supported on Linux, not %s", runtime.GOOS)
|
||||
}
|
||||
if os.Geteuid() != 0 {
|
||||
return fmt.Errorf("auth-daemon must be run as root (use sudo)")
|
||||
}
|
||||
|
||||
principalsFile := n.config.AuthDaemonPrincipalsFile
|
||||
if principalsFile == "" {
|
||||
principalsFile = defaultPrincipalsPath
|
||||
}
|
||||
caCertPath := n.config.AuthDaemonCACertPath
|
||||
if caCertPath == "" {
|
||||
caCertPath = defaultCACertPath
|
||||
}
|
||||
|
||||
cfg := authdaemon.Config{
|
||||
DisableHTTPS: true,
|
||||
PresharedKey: "this-key-is-not-used",
|
||||
PrincipalsFilePath: principalsFile,
|
||||
CACertPath: caCertPath,
|
||||
Force: true,
|
||||
GenerateRandomPassword: n.config.AuthDaemonGenerateRandomPassword,
|
||||
}
|
||||
|
||||
srv, err := authdaemon.NewServer(cfg)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create auth daemon server: %w", err)
|
||||
}
|
||||
|
||||
n.authDaemonServer = srv
|
||||
|
||||
go func() {
|
||||
logger.Debug("Auth daemon starting (native mode, no HTTP server)")
|
||||
if err := srv.Run(ctx); err != nil {
|
||||
logger.Error("Auth daemon error: %v", err)
|
||||
}
|
||||
logger.Info("Auth daemon stopped")
|
||||
}()
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -1,127 +0,0 @@
|
||||
package newt
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/fosrl/newt/logger"
|
||||
"github.com/fosrl/newt/websocket"
|
||||
"github.com/fsnotify/fsnotify"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// interpolateBlueprint replaces {{env.VAR}} tokens with their environment variable values.
|
||||
func interpolateBlueprint(data []byte) []byte {
|
||||
re := regexp.MustCompile(`\{\{([^}]+)\}\}`)
|
||||
return re.ReplaceAllFunc(data, func(match []byte) []byte {
|
||||
inner := strings.TrimSpace(string(match[2 : len(match)-2]))
|
||||
|
||||
if strings.HasPrefix(inner, "env.") {
|
||||
varName := strings.TrimPrefix(inner, "env.")
|
||||
return []byte(os.Getenv(varName))
|
||||
}
|
||||
|
||||
return match
|
||||
})
|
||||
}
|
||||
|
||||
func sendBlueprint(client *websocket.Client, file string) error {
|
||||
if file == "" {
|
||||
return nil
|
||||
}
|
||||
blueprintData, err := os.ReadFile(file)
|
||||
if err != nil {
|
||||
logger.Error("Failed to read blueprint file: %v", err)
|
||||
return nil
|
||||
}
|
||||
|
||||
blueprintData = interpolateBlueprint(blueprintData)
|
||||
|
||||
var yamlObj interface{}
|
||||
if err := yaml.Unmarshal(blueprintData, &yamlObj); err != nil {
|
||||
logger.Error("Failed to parse blueprint YAML: %v", err)
|
||||
return nil
|
||||
}
|
||||
|
||||
jsonBytes, err := json.Marshal(yamlObj)
|
||||
if err != nil {
|
||||
logger.Error("Failed to convert blueprint to JSON: %v", err)
|
||||
return nil
|
||||
}
|
||||
|
||||
blueprintJsonData := string(jsonBytes)
|
||||
logger.Debug("Converted blueprint to JSON: %s", blueprintJsonData)
|
||||
|
||||
if blueprintJsonData == "" {
|
||||
logger.Error("No valid blueprint JSON data to send to server")
|
||||
return nil
|
||||
}
|
||||
|
||||
logger.Info("Sending blueprint to server for application")
|
||||
|
||||
return client.SendMessage("newt/blueprint/apply", map[string]interface{}{
|
||||
"blueprint": blueprintJsonData,
|
||||
})
|
||||
}
|
||||
|
||||
func watchBlueprintFile(ctx context.Context, filePath string, send func() error) {
|
||||
watcher, err := fsnotify.NewWatcher()
|
||||
if err != nil {
|
||||
logger.Error("blueprint watcher: failed to create: %v", err)
|
||||
return
|
||||
}
|
||||
defer watcher.Close()
|
||||
|
||||
if err := watcher.Add(filePath); err != nil {
|
||||
logger.Error("blueprint watcher: failed to watch %s: %v", filePath, err)
|
||||
return
|
||||
}
|
||||
|
||||
logger.Info("Watching blueprint file for changes: %s", filePath)
|
||||
|
||||
var debounce *time.Timer
|
||||
scheduleSend := func() {
|
||||
if debounce != nil {
|
||||
debounce.Stop()
|
||||
}
|
||||
debounce = time.AfterFunc(500*time.Millisecond, func() {
|
||||
logger.Info("Blueprint file changed, resending...")
|
||||
if err := send(); err != nil {
|
||||
logger.Error("blueprint watcher: resend failed: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
if debounce != nil {
|
||||
debounce.Stop()
|
||||
}
|
||||
return
|
||||
case event, ok := <-watcher.Events:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
switch {
|
||||
case event.Has(fsnotify.Write) || event.Has(fsnotify.Create):
|
||||
if event.Has(fsnotify.Create) {
|
||||
_ = watcher.Add(filePath)
|
||||
}
|
||||
scheduleSend()
|
||||
case event.Has(fsnotify.Remove) || event.Has(fsnotify.Rename):
|
||||
_ = watcher.Add(filePath)
|
||||
scheduleSend()
|
||||
}
|
||||
case err, ok := <-watcher.Errors:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
logger.Error("blueprint watcher: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
114
newt/clients.go
114
newt/clients.go
@@ -1,114 +0,0 @@
|
||||
package newt
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
wgnetstack "github.com/fosrl/newt/clients"
|
||||
"github.com/fosrl/newt/clients/permissions"
|
||||
"github.com/fosrl/newt/logger"
|
||||
"golang.zx2c4.com/wireguard/tun/netstack"
|
||||
)
|
||||
|
||||
func checkNativeMainPermissions() error {
|
||||
return permissions.CheckNativeInterfacePermissions()
|
||||
}
|
||||
|
||||
func (n *Newt) setupClients() {
|
||||
host := n.config.Endpoint
|
||||
if strings.HasPrefix(host, "http://") {
|
||||
host = strings.TrimPrefix(host, "http://")
|
||||
} else if strings.HasPrefix(host, "https://") {
|
||||
host = strings.TrimPrefix(host, "https://")
|
||||
}
|
||||
host = strings.TrimSuffix(host, "/")
|
||||
|
||||
logger.Debug("Setting up clients with netstack2...")
|
||||
|
||||
if n.config.UseNativeInterface {
|
||||
logger.Debug("Checking permissions for native interface")
|
||||
if err := permissions.CheckNativeInterfacePermissions(); err != nil {
|
||||
logger.Fatal("Insufficient permissions to create native TUN interface: %v", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
var err error
|
||||
n.wgService, err = wgnetstack.NewWireGuardService(
|
||||
n.config.InterfaceName,
|
||||
n.config.Port,
|
||||
n.config.MTU,
|
||||
host,
|
||||
n.config.ID,
|
||||
n.client,
|
||||
n.config.DNS,
|
||||
n.config.UseNativeInterface,
|
||||
)
|
||||
if err != nil {
|
||||
logger.Fatal("Failed to create WireGuard service: %v", err)
|
||||
}
|
||||
|
||||
n.wgService.SetCredentialStore(n.sshCredStore)
|
||||
|
||||
n.client.OnTokenUpdate(func(token string) {
|
||||
n.wgService.SetToken(token)
|
||||
})
|
||||
|
||||
n.ready = true
|
||||
}
|
||||
|
||||
func (n *Newt) setDownstreamTNetstack(tnet *netstack.Net) {
|
||||
if n.wgService != nil {
|
||||
n.wgService.SetOthertnet(tnet)
|
||||
}
|
||||
}
|
||||
|
||||
func (n *Newt) closeClients() {
|
||||
logger.Info("Closing clients...")
|
||||
if n.wgService != nil {
|
||||
n.wgService.Close()
|
||||
n.wgService = nil
|
||||
}
|
||||
}
|
||||
|
||||
func (n *Newt) setClientsBlocked(v bool) {
|
||||
if n.wgService != nil {
|
||||
n.wgService.SetBlocked(v)
|
||||
}
|
||||
}
|
||||
|
||||
func (n *Newt) clientsHandleNewtConnection(publicKey string, endpoint string, relayPort uint16) {
|
||||
if !n.ready {
|
||||
return
|
||||
}
|
||||
|
||||
parts := strings.Split(endpoint, ":")
|
||||
if len(parts) < 2 {
|
||||
logger.Error("Invalid endpoint format: %s", endpoint)
|
||||
return
|
||||
}
|
||||
endpoint = strings.Join(parts[:len(parts)-1], ":")
|
||||
|
||||
if n.wgService != nil {
|
||||
n.wgService.StartHolepunch(publicKey, endpoint, relayPort)
|
||||
}
|
||||
}
|
||||
|
||||
func (n *Newt) clientsOnConnect() {
|
||||
if !n.ready {
|
||||
return
|
||||
}
|
||||
if n.wgService != nil {
|
||||
n.wgService.LoadRemoteConfig()
|
||||
}
|
||||
}
|
||||
|
||||
func (n *Newt) clientsStartDirectRelay(tunnelIP string) {
|
||||
if !n.ready {
|
||||
return
|
||||
}
|
||||
if n.wgService != nil {
|
||||
if err := n.wgService.StartDirectUDPRelay(tunnelIP); err != nil {
|
||||
logger.Error("Failed to start direct UDP relay: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
package newt
|
||||
|
||||
import "time"
|
||||
|
||||
// Config holds all runtime configuration for a Newt instance.
|
||||
type Config struct {
|
||||
// Build info
|
||||
Version string
|
||||
Platform string
|
||||
|
||||
// Logging
|
||||
LogLevel string
|
||||
|
||||
// Connection
|
||||
Endpoint string
|
||||
ID string
|
||||
Secret string
|
||||
ProvisioningKey string
|
||||
NewtName string
|
||||
ConfigFile string
|
||||
|
||||
// Network
|
||||
MTU int
|
||||
DNS string
|
||||
InterfaceName string
|
||||
Port uint16
|
||||
UseNativeInterface bool
|
||||
UseNativeMainInterface bool
|
||||
NativeMainInterfaceName string
|
||||
NoCloud bool
|
||||
PreferEndpoint string
|
||||
|
||||
// Timing
|
||||
PingInterval time.Duration
|
||||
PingTimeout time.Duration
|
||||
UDPProxyIdleTimeout time.Duration
|
||||
|
||||
// Features
|
||||
DisableClients bool
|
||||
DisableSSH bool
|
||||
EnforceHealthcheckCert bool
|
||||
HealthFile string
|
||||
BlueprintFile string
|
||||
ProvisioningBlueprintFile string
|
||||
UpdownScript string
|
||||
|
||||
// Docker
|
||||
DockerSocket string
|
||||
DockerEnforceNetworkValidation bool
|
||||
|
||||
// Auth daemon
|
||||
AuthDaemonKey string
|
||||
AuthDaemonPrincipalsFile string
|
||||
AuthDaemonCACertPath string
|
||||
AuthDaemonGenerateRandomPassword bool
|
||||
|
||||
// TLS (mTLS)
|
||||
TLSClientCert string
|
||||
TLSClientKey string
|
||||
TLSClientCAs []string
|
||||
TLSPrivateKey string
|
||||
|
||||
// Metrics/observability
|
||||
MetricsEnabled bool
|
||||
OTLPEnabled bool
|
||||
AdminAddr string
|
||||
Region string
|
||||
MetricsAsyncBytes bool
|
||||
PprofEnabled bool
|
||||
|
||||
// Callbacks
|
||||
OnRestart func() error
|
||||
}
|
||||
351
newt/connect.go
351
newt/connect.go
@@ -1,351 +0,0 @@
|
||||
package newt
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/netip"
|
||||
"runtime"
|
||||
"time"
|
||||
|
||||
"github.com/fosrl/newt/browsergateway"
|
||||
newtDevice "github.com/fosrl/newt/device"
|
||||
"github.com/fosrl/newt/internal/telemetry"
|
||||
"github.com/fosrl/newt/logger"
|
||||
"github.com/fosrl/newt/network"
|
||||
"github.com/fosrl/newt/proxy"
|
||||
"github.com/fosrl/newt/util"
|
||||
"github.com/fosrl/newt/websocket"
|
||||
"golang.zx2c4.com/wireguard/conn"
|
||||
"golang.zx2c4.com/wireguard/device"
|
||||
wtun "golang.zx2c4.com/wireguard/tun"
|
||||
"golang.zx2c4.com/wireguard/tun/netstack"
|
||||
)
|
||||
|
||||
func (n *Newt) handleConnect(ctx context.Context, msg websocket.WSMessage) {
|
||||
logger.Debug("Received registration message")
|
||||
regResult := "success"
|
||||
defer func() {
|
||||
telemetry.IncSiteRegistration(ctx, regResult)
|
||||
}()
|
||||
|
||||
var chainData struct {
|
||||
ChainId string `json:"chainId"`
|
||||
}
|
||||
if jsonBytes, err := json.Marshal(msg.Data); err == nil {
|
||||
_ = json.Unmarshal(jsonBytes, &chainData)
|
||||
}
|
||||
if chainData.ChainId != "" {
|
||||
if chainData.ChainId != n.pendingRegisterChainId {
|
||||
logger.Debug("Discarding duplicate/stale newt/wg/connect (chainId=%s, expected=%s)", chainData.ChainId, n.pendingRegisterChainId)
|
||||
return
|
||||
}
|
||||
n.pendingRegisterChainId = ""
|
||||
}
|
||||
|
||||
if n.stopFunc != nil {
|
||||
n.stopFunc()
|
||||
n.stopFunc = nil
|
||||
}
|
||||
|
||||
if n.connected {
|
||||
n.closeWgTunnel()
|
||||
n.connected = false
|
||||
}
|
||||
|
||||
logger.Debug("Received registration message data: %+v", msg.Data)
|
||||
|
||||
jsonData, err := json.Marshal(msg.Data)
|
||||
if err != nil {
|
||||
logger.Info(fmtErrMarshaling, err)
|
||||
regResult = "failure"
|
||||
return
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(jsonData, &n.wgData); err != nil {
|
||||
logger.Info("Error unmarshaling target data: %v", err)
|
||||
regResult = "failure"
|
||||
return
|
||||
}
|
||||
|
||||
logger.Debug(fmtReceivedMsg, msg)
|
||||
|
||||
if n.config.UseNativeMainInterface {
|
||||
mainIfName := n.config.NativeMainInterfaceName
|
||||
if runtime.GOOS == "darwin" {
|
||||
mainIfName, err = network.FindUnusedUTUN()
|
||||
if err != nil {
|
||||
logger.Error("Failed to find unused utun for main tunnel: %v", err)
|
||||
regResult = "failure"
|
||||
return
|
||||
}
|
||||
}
|
||||
n.tun, err = wtun.CreateTUN(mainIfName, n.config.MTU)
|
||||
if err != nil {
|
||||
logger.Error("Failed to create native main TUN device: %v", err)
|
||||
regResult = "failure"
|
||||
return
|
||||
}
|
||||
if realName, nameErr := n.tun.Name(); nameErr == nil {
|
||||
mainIfName = realName
|
||||
}
|
||||
n.tnet = nil
|
||||
n.config.NativeMainInterfaceName = mainIfName
|
||||
} else {
|
||||
n.tun, n.tnet, err = netstack.CreateNetTUN(
|
||||
[]netip.Addr{netip.MustParseAddr(n.wgData.TunnelIP)},
|
||||
[]netip.Addr{netip.MustParseAddr(n.config.DNS)},
|
||||
n.config.MTU)
|
||||
if err != nil {
|
||||
logger.Error("Failed to create TUN device: %v", err)
|
||||
regResult = "failure"
|
||||
}
|
||||
}
|
||||
|
||||
n.setDownstreamTNetstack(n.tnet)
|
||||
|
||||
n.dev = device.NewDevice(n.tun, conn.NewDefaultBind(), device.NewLogger(
|
||||
util.MapToWireGuardLogLevel(n.loggerLevel),
|
||||
"gerbil-wireguard: ",
|
||||
))
|
||||
|
||||
host, _, err := net.SplitHostPort(n.wgData.Endpoint)
|
||||
if err != nil {
|
||||
logger.Error("Failed to split endpoint: %v", err)
|
||||
regResult = "failure"
|
||||
return
|
||||
}
|
||||
|
||||
logger.Info("Connecting to endpoint: %s", host)
|
||||
|
||||
resolvedEndpoint, err := util.ResolveDomain(n.wgData.Endpoint)
|
||||
if err != nil {
|
||||
logger.Error("Failed to resolve endpoint: %v", err)
|
||||
regResult = "failure"
|
||||
return
|
||||
}
|
||||
|
||||
relayPort := n.wgData.RelayPort
|
||||
if relayPort == 0 {
|
||||
relayPort = 21820
|
||||
}
|
||||
|
||||
n.clientsHandleNewtConnection(n.wgData.PublicKey, resolvedEndpoint, relayPort)
|
||||
|
||||
wgConfig := fmt.Sprintf(`private_key=%s
|
||||
public_key=%s
|
||||
allowed_ip=%s/32
|
||||
endpoint=%s
|
||||
persistent_keepalive_interval=5`, util.FixKey(n.privateKey.String()), util.FixKey(n.wgData.PublicKey), n.wgData.ServerIP, resolvedEndpoint)
|
||||
|
||||
if err = n.dev.IpcSet(wgConfig); err != nil {
|
||||
logger.Error("Failed to configure WireGuard device: %v", err)
|
||||
regResult = "failure"
|
||||
}
|
||||
|
||||
if err = n.dev.Up(); err != nil {
|
||||
logger.Error("Failed to bring up WireGuard device: %v", err)
|
||||
regResult = "failure"
|
||||
}
|
||||
|
||||
if n.config.UseNativeMainInterface {
|
||||
if cfgErr := network.ConfigureInterface(n.config.NativeMainInterfaceName, n.wgData.TunnelIP+"/32", n.config.MTU); cfgErr != nil {
|
||||
logger.Error("Failed to configure native main tunnel interface: %v", cfgErr)
|
||||
}
|
||||
if routeErr := network.AddRoutes([]string{n.wgData.ServerIP + "/32"}, n.config.NativeMainInterfaceName); routeErr != nil {
|
||||
logger.Warn("Failed to add route for main tunnel server IP: %v", routeErr)
|
||||
}
|
||||
if fileUAPI, uapiErr := newtDevice.UapiOpen(n.config.NativeMainInterfaceName); uapiErr != nil {
|
||||
logger.Warn("Main tunnel UAPI open error: %v", uapiErr)
|
||||
} else if uapiListener, uapiListenErr := newtDevice.UapiListen(n.config.NativeMainInterfaceName, fileUAPI); uapiListenErr != nil {
|
||||
logger.Warn("Main tunnel UAPI listen error: %v", uapiListenErr)
|
||||
} else {
|
||||
go func() {
|
||||
for {
|
||||
c, aErr := uapiListener.Accept()
|
||||
if aErr != nil {
|
||||
return
|
||||
}
|
||||
go n.dev.IpcHandle(c)
|
||||
}
|
||||
}()
|
||||
logger.Debug("Main tunnel UAPI listener started on %s", n.config.NativeMainInterfaceName)
|
||||
}
|
||||
}
|
||||
|
||||
n.activeRemoteSubnets = nil
|
||||
if len(n.wgData.RemoteExitNodeSubnets) > 0 {
|
||||
for _, subnet := range n.wgData.RemoteExitNodeSubnets {
|
||||
subnetCfg := fmt.Sprintf("public_key=%s\nallowed_ip=%s", util.FixKey(n.wgData.PublicKey), subnet)
|
||||
if err := n.dev.IpcSet(subnetCfg); err != nil {
|
||||
logger.Warn("Failed to add AllowedIP %s to main tunnel: %v", subnet, err)
|
||||
}
|
||||
}
|
||||
if n.config.UseNativeMainInterface {
|
||||
if routeErr := network.AddRoutes(n.wgData.RemoteExitNodeSubnets, n.config.NativeMainInterfaceName); routeErr != nil {
|
||||
logger.Warn("Failed to add routes for remote exit node subnets: %v", routeErr)
|
||||
}
|
||||
}
|
||||
n.activeRemoteSubnets = append([]string{}, n.wgData.RemoteExitNodeSubnets...)
|
||||
logger.Debug("Added %d remote exit node subnets", len(n.wgData.RemoteExitNodeSubnets))
|
||||
}
|
||||
|
||||
logger.Debug("WireGuard device created. Lets ping the server now...")
|
||||
|
||||
if n.pingWithRetryStopChan != nil {
|
||||
close(n.pingWithRetryStopChan)
|
||||
n.pingWithRetryStopChan = nil
|
||||
}
|
||||
|
||||
var pinger pingFunc
|
||||
if n.config.UseNativeMainInterface {
|
||||
pinger = pingNative
|
||||
} else {
|
||||
pinger = func(dst string, timeout time.Duration) (time.Duration, error) {
|
||||
return ping(n.tnet, dst, timeout)
|
||||
}
|
||||
}
|
||||
|
||||
logger.Debug("Testing initial connection with reliable ping...")
|
||||
lat, err := reliablePing(pinger, n.wgData.ServerIP, n.config.PingTimeout, 5)
|
||||
if err == nil && n.wgData.PublicKey != "" {
|
||||
telemetry.ObserveTunnelLatency(ctx, n.wgData.PublicKey, "wireguard", lat.Seconds())
|
||||
}
|
||||
if err != nil {
|
||||
logger.Warn("Initial reliable ping failed, but continuing: %v", err)
|
||||
regResult = "failure"
|
||||
} else {
|
||||
logger.Debug("Initial connection test successful")
|
||||
}
|
||||
|
||||
n.pingWithRetryStopChan, _ = n.pingWithRetry(pinger, n.wgData.ServerIP, n.config.PingTimeout)
|
||||
|
||||
if !n.connected {
|
||||
logger.Debug("Starting ping check")
|
||||
n.pingStopChan = n.startPingCheck(pinger, n.wgData.ServerIP, n.wgData.PublicKey)
|
||||
}
|
||||
|
||||
if n.config.UseNativeMainInterface {
|
||||
n.pm = proxy.NewProxyManagerNative(n.wgData.TunnelIP)
|
||||
} else {
|
||||
n.pm = proxy.NewProxyManager(n.tnet)
|
||||
}
|
||||
n.pm.SetAsyncBytes(n.config.MetricsAsyncBytes)
|
||||
n.pm.SetUDPIdleTimeout(n.config.UDPProxyIdleTimeout)
|
||||
n.pm.SetTunnelID(n.wgData.PublicKey)
|
||||
n.pm.SetBlocked(n.connectionBlocked.Load())
|
||||
n.currentPM.Store(n.pm)
|
||||
|
||||
n.connected = true
|
||||
|
||||
if len(n.wgData.Targets.TCP) > 0 {
|
||||
n.updateTargets(n.pm, "add", n.wgData.TunnelIP, "tcp", TargetData{Targets: n.wgData.Targets.TCP})
|
||||
}
|
||||
if len(n.wgData.Targets.UDP) > 0 {
|
||||
n.updateTargets(n.pm, "add", n.wgData.TunnelIP, "udp", TargetData{Targets: n.wgData.Targets.UDP})
|
||||
}
|
||||
|
||||
if !n.config.UseNativeMainInterface {
|
||||
n.clientsStartDirectRelay(n.wgData.TunnelIP)
|
||||
}
|
||||
|
||||
if err := n.healthMonitor.AddTargets(n.wgData.HealthCheckTargets); err != nil {
|
||||
logger.Error("Failed to bulk add health check targets: %v", err)
|
||||
} else {
|
||||
logger.Debug("Successfully added %d health check targets", len(n.wgData.HealthCheckTargets))
|
||||
}
|
||||
|
||||
if err = n.pm.Start(); err != nil {
|
||||
logger.Error("Failed to start proxy manager: %v", err)
|
||||
}
|
||||
|
||||
if len(n.wgData.BrowserGatewayTargets) > 0 {
|
||||
// The netstack is fresh on (re)connect, so any previously running
|
||||
// gateway listener is bound to a now-defunct interface - tear it down.
|
||||
if n.browserGatewayStop != nil {
|
||||
n.browserGatewayStop()
|
||||
n.browserGatewayStop = nil
|
||||
n.browserGateway = nil
|
||||
}
|
||||
|
||||
if err := n.startBrowserGateway(); err != nil {
|
||||
logger.Error("Failed to start browser gateway listener: %v", err)
|
||||
} else {
|
||||
n.browserGateway.SetTargets(toBrowserGatewayTargets(n.wgData.BrowserGatewayTargets))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// startBrowserGateway creates the browser gateway and its listener if one
|
||||
// isn't already running. Callers that need to rebind to a fresh netstack
|
||||
// (e.g. on reconnect) must stop and clear any existing gateway first.
|
||||
func (n *Newt) startBrowserGateway() error {
|
||||
if n.browserGateway != nil {
|
||||
return nil
|
||||
}
|
||||
if n.tnet == nil && !n.config.UseNativeMainInterface {
|
||||
return fmt.Errorf("netstack not ready")
|
||||
}
|
||||
|
||||
gateway := browsergateway.New(browsergateway.Config{SSHCredentials: n.sshCredStore})
|
||||
|
||||
var ln net.Listener
|
||||
var err error
|
||||
if n.config.UseNativeMainInterface {
|
||||
ln, err = net.Listen("tcp", fmt.Sprintf("%s:%d", n.wgData.TunnelIP, browsergateway.ListenPort))
|
||||
} else {
|
||||
ln, err = n.tnet.ListenTCP(&net.TCPAddr{Port: browsergateway.ListenPort})
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
n.browserGateway = gateway
|
||||
n.browserGatewayStop = func() { _ = ln.Close() }
|
||||
go func() {
|
||||
logger.Debug("Browser gateway started on port %d", browsergateway.ListenPort)
|
||||
if startErr := gateway.Start(ln); startErr != nil {
|
||||
logger.Error("Browser gateway stopped with error: %v", startErr)
|
||||
}
|
||||
}()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// syncBrowserGatewayTargets reconciles the browser gateway's allowed
|
||||
// destinations with the desired state received from a sync message.
|
||||
// It lazily starts the gateway if targets are present and it isn't running
|
||||
// yet, and clears the allow-list (without tearing down the listener) when
|
||||
// no targets are desired.
|
||||
func (n *Newt) syncBrowserGatewayTargets(targets []BrowserGatewayTarget) {
|
||||
bgTargets := toBrowserGatewayTargets(targets)
|
||||
|
||||
if len(bgTargets) == 0 {
|
||||
if n.browserGateway != nil {
|
||||
n.browserGateway.SetTargets(nil)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if err := n.startBrowserGateway(); err != nil {
|
||||
logger.Error("Failed to start browser gateway: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
n.browserGateway.SetTargets(bgTargets)
|
||||
}
|
||||
|
||||
func toBrowserGatewayTargets(targets []BrowserGatewayTarget) []browsergateway.Target {
|
||||
bgTargets := make([]browsergateway.Target, 0, len(targets))
|
||||
for _, t := range targets {
|
||||
bgTargets = append(bgTargets, browsergateway.Target{
|
||||
ID: t.ID,
|
||||
Type: t.Type,
|
||||
Destination: t.Destination,
|
||||
DestinationPort: t.DestinationPort,
|
||||
AuthToken: t.AuthToken,
|
||||
})
|
||||
}
|
||||
return bgTargets
|
||||
}
|
||||
161
newt/data.go
161
newt/data.go
@@ -1,161 +0,0 @@
|
||||
package newt
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/fosrl/newt/logger"
|
||||
"github.com/fosrl/newt/websocket"
|
||||
)
|
||||
|
||||
func (n *Newt) handleSync(msg websocket.WSMessage) {
|
||||
logger.Info("Received sync message")
|
||||
|
||||
// if there is no wgData or pm, we can't sync targets
|
||||
if n.wgData.TunnelIP == "" || n.pm == nil {
|
||||
logger.Info(msgNoTunnelOrProxy)
|
||||
return
|
||||
}
|
||||
|
||||
var syncData SyncData
|
||||
jsonData, err := json.Marshal(msg.Data)
|
||||
if err != nil {
|
||||
logger.Error("Error marshaling sync data: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(jsonData, &syncData); err != nil {
|
||||
logger.Error("Error unmarshaling sync data: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
logger.Debug("Sync data received: TCP targets=%d, UDP targets=%d, health check targets=%d",
|
||||
len(syncData.Targets.TCP), len(syncData.Targets.UDP), len(syncData.HealthCheckTargets))
|
||||
|
||||
// Build sets of desired targets (port -> target string)
|
||||
desiredTCP := make(map[int]string)
|
||||
for _, t := range syncData.Targets.TCP {
|
||||
parts := strings.Split(t, ":")
|
||||
if len(parts) != 3 {
|
||||
logger.Warn("Invalid TCP target format: %s", t)
|
||||
continue
|
||||
}
|
||||
port := 0
|
||||
if _, err := fmt.Sscanf(parts[0], "%d", &port); err != nil {
|
||||
logger.Warn("Invalid port in TCP target: %s", parts[0])
|
||||
continue
|
||||
}
|
||||
desiredTCP[port] = parts[1] + ":" + parts[2]
|
||||
}
|
||||
|
||||
desiredUDP := make(map[int]string)
|
||||
for _, t := range syncData.Targets.UDP {
|
||||
parts := strings.Split(t, ":")
|
||||
if len(parts) != 3 {
|
||||
logger.Warn("Invalid UDP target format: %s", t)
|
||||
continue
|
||||
}
|
||||
port := 0
|
||||
if _, err := fmt.Sscanf(parts[0], "%d", &port); err != nil {
|
||||
logger.Warn("Invalid port in UDP target: %s", parts[0])
|
||||
continue
|
||||
}
|
||||
desiredUDP[port] = parts[1] + ":" + parts[2]
|
||||
}
|
||||
|
||||
// Get current targets from proxy manager
|
||||
currentTCP, currentUDP := n.pm.GetTargets()
|
||||
|
||||
// Sync TCP targets
|
||||
// Remove TCP targets not in desired set
|
||||
if tcpForIP, ok := currentTCP[n.wgData.TunnelIP]; ok {
|
||||
for port := range tcpForIP {
|
||||
if _, exists := desiredTCP[port]; !exists {
|
||||
logger.Info("Sync: removing TCP target on port %d", port)
|
||||
targetStr := fmt.Sprintf("%d:%s", port, tcpForIP[port])
|
||||
n.updateTargets(n.pm, "remove", n.wgData.TunnelIP, "tcp", TargetData{Targets: []string{targetStr}})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add TCP targets that are missing
|
||||
for port, target := range desiredTCP {
|
||||
needsAdd := true
|
||||
if tcpForIP, ok := currentTCP[n.wgData.TunnelIP]; ok {
|
||||
if currentTarget, exists := tcpForIP[port]; exists {
|
||||
// Check if target address changed
|
||||
if currentTarget == target {
|
||||
needsAdd = false
|
||||
} else {
|
||||
// Target changed, remove old one first
|
||||
logger.Info("Sync: updating TCP target on port %d", port)
|
||||
targetStr := fmt.Sprintf("%d:%s", port, currentTarget)
|
||||
n.updateTargets(n.pm, "remove", n.wgData.TunnelIP, "tcp", TargetData{Targets: []string{targetStr}})
|
||||
}
|
||||
}
|
||||
}
|
||||
if needsAdd {
|
||||
logger.Info("Sync: adding TCP target on port %d -> %s", port, target)
|
||||
targetStr := fmt.Sprintf("%d:%s", port, target)
|
||||
n.updateTargets(n.pm, "add", n.wgData.TunnelIP, "tcp", TargetData{Targets: []string{targetStr}})
|
||||
}
|
||||
}
|
||||
|
||||
// Sync UDP targets
|
||||
// Remove UDP targets not in desired set
|
||||
if udpForIP, ok := currentUDP[n.wgData.TunnelIP]; ok {
|
||||
for port := range udpForIP {
|
||||
if _, exists := desiredUDP[port]; !exists {
|
||||
logger.Info("Sync: removing UDP target on port %d", port)
|
||||
targetStr := fmt.Sprintf("%d:%s", port, udpForIP[port])
|
||||
n.updateTargets(n.pm, "remove", n.wgData.TunnelIP, "udp", TargetData{Targets: []string{targetStr}})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add UDP targets that are missing
|
||||
for port, target := range desiredUDP {
|
||||
needsAdd := true
|
||||
if udpForIP, ok := currentUDP[n.wgData.TunnelIP]; ok {
|
||||
if currentTarget, exists := udpForIP[port]; exists {
|
||||
// Check if target address changed
|
||||
if currentTarget == target {
|
||||
needsAdd = false
|
||||
} else {
|
||||
// Target changed, remove old one first
|
||||
logger.Info("Sync: updating UDP target on port %d", port)
|
||||
targetStr := fmt.Sprintf("%d:%s", port, currentTarget)
|
||||
n.updateTargets(n.pm, "remove", n.wgData.TunnelIP, "udp", TargetData{Targets: []string{targetStr}})
|
||||
}
|
||||
}
|
||||
}
|
||||
if needsAdd {
|
||||
logger.Info("Sync: adding UDP target on port %d -> %s", port, target)
|
||||
targetStr := fmt.Sprintf("%d:%s", port, target)
|
||||
n.updateTargets(n.pm, "add", n.wgData.TunnelIP, "udp", TargetData{Targets: []string{targetStr}})
|
||||
}
|
||||
}
|
||||
|
||||
// Sync remote exit node subnets
|
||||
if n.dev != nil {
|
||||
n.updateRemoteExitNodeSubnets(syncData.RemoteExitNodeSubnets)
|
||||
}
|
||||
|
||||
// Sync clients WireGuard peers and targets, if clients are set up
|
||||
if n.wgService != nil {
|
||||
n.wgService.Sync(syncData.Peers, syncData.ClientTargets)
|
||||
}
|
||||
|
||||
// Sync browser gateway targets
|
||||
n.syncBrowserGatewayTargets(syncData.BrowserGatewayTargets)
|
||||
|
||||
// Sync health check targets
|
||||
if err := n.healthMonitor.SyncTargets(syncData.HealthCheckTargets); err != nil {
|
||||
logger.Error("Failed to sync health check targets: %v", err)
|
||||
} else {
|
||||
logger.Info("Successfully synced health check targets")
|
||||
}
|
||||
|
||||
logger.Info("Sync complete")
|
||||
}
|
||||
1082
newt/handlers.go
1082
newt/handlers.go
File diff suppressed because it is too large
Load Diff
291
newt/newt.go
291
newt/newt.go
@@ -1,291 +0,0 @@
|
||||
package newt
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/fosrl/newt/authdaemon"
|
||||
"github.com/fosrl/newt/browsergateway"
|
||||
wgclients "github.com/fosrl/newt/clients"
|
||||
"github.com/fosrl/newt/docker"
|
||||
"github.com/fosrl/newt/healthcheck"
|
||||
"github.com/fosrl/newt/logger"
|
||||
"github.com/fosrl/newt/nativessh"
|
||||
"github.com/fosrl/newt/proxy"
|
||||
"github.com/fosrl/newt/util"
|
||||
"github.com/fosrl/newt/websocket"
|
||||
"golang.zx2c4.com/wireguard/device"
|
||||
wtun "golang.zx2c4.com/wireguard/tun"
|
||||
"golang.zx2c4.com/wireguard/tun/netstack"
|
||||
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
|
||||
)
|
||||
|
||||
// Newt holds all runtime state for a newt tunnel instance.
|
||||
type Newt struct {
|
||||
config Config
|
||||
client *websocket.Client
|
||||
privateKey wgtypes.Key
|
||||
publicKey wgtypes.Key
|
||||
loggerLevel logger.LogLevel
|
||||
tlsOpt websocket.ClientOption
|
||||
|
||||
// WireGuard tunnel state
|
||||
tun wtun.Device
|
||||
tnet *netstack.Net
|
||||
dev *device.Device
|
||||
|
||||
// Proxy / networking
|
||||
pm *proxy.ProxyManager
|
||||
currentPM atomic.Pointer[proxy.ProxyManager]
|
||||
connectionBlocked atomic.Bool
|
||||
activeRemoteSubnets []string
|
||||
|
||||
// Ping state
|
||||
pingStopChan chan struct{}
|
||||
pingWithRetryStopChan chan struct{}
|
||||
|
||||
// Connection / messaging state
|
||||
connected bool
|
||||
stopFunc func()
|
||||
pendingRegisterChainId string
|
||||
pendingPingChainId string
|
||||
|
||||
// Browser gateway
|
||||
browserGateway *browsergateway.Gateway
|
||||
browserGatewayStop func()
|
||||
|
||||
// Health monitoring
|
||||
healthMonitor *healthcheck.Monitor
|
||||
|
||||
// Downstream WireGuard client management
|
||||
wgService *wgclients.WireGuardService
|
||||
ready bool
|
||||
sshCredStore *nativessh.CredentialStore
|
||||
|
||||
// Auth daemon (Linux only)
|
||||
authDaemonServer *authdaemon.Server
|
||||
|
||||
// Docker monitoring
|
||||
dockerEventMonitor *docker.EventMonitor
|
||||
|
||||
// Current tunnel data
|
||||
wgData WgData
|
||||
}
|
||||
|
||||
// Init creates and initialises a Newt instance. It sets up the websocket
|
||||
// client, generates WireGuard keys, and starts the auth daemon if enabled.
|
||||
// Callers should invoke Start after any additional setup (telemetry, etc.).
|
||||
func Init(ctx context.Context, cfg Config) (*Newt, error) {
|
||||
n := &Newt{config: cfg}
|
||||
|
||||
n.loggerLevel = util.ParseLogLevel(cfg.LogLevel)
|
||||
|
||||
if !cfg.DisableSSH {
|
||||
if err := n.startAuthDaemon(ctx); err != nil {
|
||||
logger.Warn("Did not start on site auth daemon: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
logger.GetLogger().SetLevel(n.loggerLevel)
|
||||
|
||||
if cfg.TLSPrivateKey != "" {
|
||||
logger.Warn("Using deprecated PKCS12 format for mTLS. Consider migrating to separate certificate files using --tls-client-cert-file, --tls-client-key, and --tls-client-ca")
|
||||
}
|
||||
|
||||
privateKey, err := wgtypes.GeneratePrivateKey()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("generate private key: %w", err)
|
||||
}
|
||||
n.privateKey = privateKey
|
||||
|
||||
if cfg.TLSClientCert != "" && cfg.TLSClientKey != "" {
|
||||
n.tlsOpt = websocket.WithTLSConfig(websocket.TLSConfig{
|
||||
ClientCertFile: cfg.TLSClientCert,
|
||||
ClientKeyFile: cfg.TLSClientKey,
|
||||
CAFiles: cfg.TLSClientCAs,
|
||||
})
|
||||
logger.Debug("Using separate certificate files for mTLS")
|
||||
logger.Debug("Client cert: %s", cfg.TLSClientCert)
|
||||
logger.Debug("Client key: %s", cfg.TLSClientKey)
|
||||
logger.Debug("CA files: %v", cfg.TLSClientCAs)
|
||||
} else if cfg.TLSPrivateKey != "" {
|
||||
n.tlsOpt = websocket.WithTLSConfig(websocket.TLSConfig{
|
||||
PKCS12File: cfg.TLSPrivateKey,
|
||||
})
|
||||
logger.Debug("Using PKCS12 file for mTLS: %s", cfg.TLSPrivateKey)
|
||||
}
|
||||
|
||||
client, err := websocket.NewClient(
|
||||
"newt",
|
||||
cfg.ID,
|
||||
cfg.Secret,
|
||||
cfg.Endpoint,
|
||||
30*time.Second,
|
||||
n.tlsOpt,
|
||||
websocket.WithConfigFile(cfg.ConfigFile),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create websocket client: %w", err)
|
||||
}
|
||||
n.client = client
|
||||
|
||||
client.GetConfig().ProvisioningKey = cfg.ProvisioningKey
|
||||
client.GetConfig().Name = cfg.NewtName
|
||||
|
||||
// Resolve provisioning synchronously so ID/Secret are final before
|
||||
// setupClients() bakes them into the WireGuard service / hole-punch
|
||||
// manager. Connect() only provisions lazily in the background, which
|
||||
// would otherwise race setupClients() on first run.
|
||||
if err := client.EnsureProvisioned(); err != nil {
|
||||
return nil, fmt.Errorf("provision newt credentials: %w", err)
|
||||
}
|
||||
|
||||
// Update config from resolved client values (provisioning / config file).
|
||||
n.config.Endpoint = client.GetConfig().Endpoint
|
||||
n.config.ID = client.GetConfig().ID
|
||||
n.config.Secret = client.GetConfig().Secret
|
||||
|
||||
if !cfg.DisableSSH {
|
||||
n.sshCredStore = nativessh.NewCredentialStore()
|
||||
}
|
||||
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// GetConfig returns the (potentially resolved) configuration.
|
||||
func (n *Newt) GetConfig() Config {
|
||||
return n.config
|
||||
}
|
||||
|
||||
// GetTLSClientOpt returns the websocket TLS option, so callers can reuse the
|
||||
// same TLS configuration for other HTTP clients (e.g. self-update).
|
||||
func (n *Newt) GetTLSClientOpt() websocket.ClientOption {
|
||||
return n.tlsOpt
|
||||
}
|
||||
|
||||
// Start sets up all WebSocket handlers, connects to the server, and blocks
|
||||
// until ctx is cancelled.
|
||||
func (n *Newt) Start(ctx context.Context) {
|
||||
if !n.config.DisableClients {
|
||||
n.setupClients()
|
||||
}
|
||||
|
||||
n.connectionBlocked.Store(n.client.GetConfig().Blocked)
|
||||
if n.connectionBlocked.Load() {
|
||||
logger.Info("Connection blocking is enabled (from config)")
|
||||
n.setClientsBlocked(true)
|
||||
}
|
||||
|
||||
n.healthMonitor = healthcheck.NewMonitor(func(targets map[int]*healthcheck.Target) {
|
||||
logger.Debug("Health check status update for %d targets", len(targets))
|
||||
|
||||
healthStatuses := make(map[int]interface{})
|
||||
for id, target := range targets {
|
||||
healthStatuses[id] = map[string]interface{}{
|
||||
"status": target.Status.String(),
|
||||
"lastCheck": target.LastCheck.Format(time.RFC3339),
|
||||
"checkCount": target.CheckCount,
|
||||
"lastError": target.LastError,
|
||||
"config": target.Config,
|
||||
}
|
||||
}
|
||||
|
||||
logger.Debug("Health check status: %+v", healthStatuses)
|
||||
|
||||
if err := n.client.SendMessage("newt/healthcheck/status", map[string]interface{}{
|
||||
"targets": healthStatuses,
|
||||
}); err != nil {
|
||||
logger.Error("Failed to send health check status update: %v", err)
|
||||
}
|
||||
}, n.config.EnforceHealthcheckCert)
|
||||
|
||||
n.registerHandlers(ctx)
|
||||
|
||||
if err := n.client.Connect(); err != nil {
|
||||
logger.Fatal("Failed to connect to server: %v", err)
|
||||
}
|
||||
defer n.client.Close()
|
||||
|
||||
if n.config.DockerSocket != "" {
|
||||
logger.Debug("Initializing Docker event monitoring")
|
||||
var err error
|
||||
n.dockerEventMonitor, err = docker.NewEventMonitor(
|
||||
n.config.DockerSocket,
|
||||
n.config.DockerEnforceNetworkValidation,
|
||||
func(containers []docker.Container) {
|
||||
logger.Debug("Docker event detected, sending updated container list (%d containers)", len(containers))
|
||||
if err := n.client.SendMessage("newt/socket/containers", map[string]interface{}{
|
||||
"containers": containers,
|
||||
}); err != nil {
|
||||
logger.Error("Failed to send updated container list after Docker event: %v", err)
|
||||
} else {
|
||||
logger.Debug("Updated container list sent successfully")
|
||||
}
|
||||
})
|
||||
if err != nil {
|
||||
logger.Error("Failed to create Docker event monitor: %v", err)
|
||||
} else {
|
||||
if err := n.dockerEventMonitor.Start(); err != nil {
|
||||
logger.Error("Failed to start Docker event monitoring: %v", err)
|
||||
} else {
|
||||
logger.Debug("Docker event monitoring started successfully")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if n.config.BlueprintFile != "" {
|
||||
go watchBlueprintFile(ctx, n.config.BlueprintFile, func() error {
|
||||
return sendBlueprint(n.client, n.config.BlueprintFile)
|
||||
})
|
||||
}
|
||||
|
||||
<-ctx.Done()
|
||||
|
||||
n.closeClients()
|
||||
|
||||
if n.dockerEventMonitor != nil {
|
||||
n.dockerEventMonitor.Stop()
|
||||
}
|
||||
|
||||
if n.healthMonitor != nil {
|
||||
n.healthMonitor.Stop()
|
||||
}
|
||||
|
||||
if n.dev != nil {
|
||||
n.dev.Close()
|
||||
}
|
||||
|
||||
if n.pm != nil {
|
||||
n.pm.Stop()
|
||||
}
|
||||
|
||||
n.client.SendMessage("newt/disconnecting", map[string]any{})
|
||||
|
||||
if n.client != nil {
|
||||
n.client.Close()
|
||||
}
|
||||
logger.Info("Exiting...")
|
||||
}
|
||||
|
||||
// Close performs an emergency shutdown: closes the tunnel, clients, health
|
||||
// monitor, and websocket connection. Typically used before re-exec.
|
||||
func (n *Newt) Close() {
|
||||
n.closeWgTunnel()
|
||||
n.closeClients()
|
||||
if n.healthMonitor != nil {
|
||||
n.healthMonitor.Stop()
|
||||
}
|
||||
if n.client != nil {
|
||||
n.client.Close()
|
||||
}
|
||||
}
|
||||
|
||||
func generateChainId() string {
|
||||
b := make([]byte, 8)
|
||||
_, _ = rand.Read(b)
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
339
newt/ping.go
339
newt/ping.go
@@ -1,339 +0,0 @@
|
||||
package newt
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"os"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
"time"
|
||||
|
||||
"github.com/fosrl/newt/internal/telemetry"
|
||||
"github.com/fosrl/newt/logger"
|
||||
"golang.org/x/net/icmp"
|
||||
"golang.org/x/net/ipv4"
|
||||
"golang.zx2c4.com/wireguard/tun/netstack"
|
||||
)
|
||||
|
||||
type pingFunc func(dst string, timeout time.Duration) (time.Duration, error)
|
||||
|
||||
const msgHealthFileWriteFailed = "Failed to write health file: %v"
|
||||
|
||||
func pingNative(dst string, timeout time.Duration) (time.Duration, error) {
|
||||
timeoutSecs := int(timeout.Seconds())
|
||||
if timeoutSecs < 1 {
|
||||
timeoutSecs = 1
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout+time.Second)
|
||||
defer cancel()
|
||||
|
||||
var cmd *exec.Cmd
|
||||
switch runtime.GOOS {
|
||||
case "windows":
|
||||
cmd = exec.CommandContext(ctx, "ping", "-n", "1", "-w", fmt.Sprintf("%d", int(timeout.Milliseconds())), dst)
|
||||
case "darwin":
|
||||
cmd = exec.CommandContext(ctx, "ping", "-c", "1", "-W", fmt.Sprintf("%d", int(timeout.Milliseconds())), dst)
|
||||
default:
|
||||
cmd = exec.CommandContext(ctx, "ping", "-c", "1", "-W", fmt.Sprintf("%d", timeoutSecs), dst)
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
if err := cmd.Run(); err != nil {
|
||||
return 0, fmt.Errorf("native ping to %s failed: %w", dst, err)
|
||||
}
|
||||
return time.Since(start), nil
|
||||
}
|
||||
|
||||
func ping(tnet *netstack.Net, dst string, timeout time.Duration) (time.Duration, error) {
|
||||
socket, err := tnet.Dial("ping4", dst)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to create ICMP socket: %w", err)
|
||||
}
|
||||
defer socket.Close()
|
||||
|
||||
if tcpConn, ok := socket.(interface{ SetReadBuffer(int) error }); ok {
|
||||
tcpConn.SetReadBuffer(64 * 1024)
|
||||
}
|
||||
if tcpConn, ok := socket.(interface{ SetWriteBuffer(int) error }); ok {
|
||||
tcpConn.SetWriteBuffer(64 * 1024)
|
||||
}
|
||||
|
||||
requestPing := icmp.Echo{
|
||||
Seq: rand.Intn(1 << 16),
|
||||
Data: []byte("newtping"),
|
||||
}
|
||||
|
||||
icmpBytes, err := (&icmp.Message{Type: ipv4.ICMPTypeEcho, Code: 0, Body: &requestPing}).Marshal(nil)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to marshal ICMP message: %w", err)
|
||||
}
|
||||
|
||||
if err := socket.SetReadDeadline(time.Now().Add(timeout)); err != nil {
|
||||
return 0, fmt.Errorf("failed to set read deadline: %w", err)
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
_, err = socket.Write(icmpBytes)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to write ICMP packet: %w", err)
|
||||
}
|
||||
|
||||
readBuffer := make([]byte, 1500)
|
||||
n, err := socket.Read(readBuffer)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to read ICMP packet: %w", err)
|
||||
}
|
||||
|
||||
replyPacket, err := icmp.ParseMessage(1, readBuffer[:n])
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to parse ICMP packet: %w", err)
|
||||
}
|
||||
|
||||
replyPing, ok := replyPacket.Body.(*icmp.Echo)
|
||||
if !ok {
|
||||
return 0, fmt.Errorf("invalid reply type: got %T, want *icmp.Echo", replyPacket.Body)
|
||||
}
|
||||
|
||||
if !bytes.Equal(replyPing.Data, requestPing.Data) || replyPing.Seq != requestPing.Seq {
|
||||
return 0, fmt.Errorf("invalid ping reply: got seq=%d data=%q, want seq=%d data=%q",
|
||||
replyPing.Seq, replyPing.Data, requestPing.Seq, requestPing.Data)
|
||||
}
|
||||
|
||||
return time.Since(start), nil
|
||||
}
|
||||
|
||||
func reliablePing(fn pingFunc, dst string, baseTimeout time.Duration, maxAttempts int) (time.Duration, error) {
|
||||
var lastErr error
|
||||
var totalLatency time.Duration
|
||||
successCount := 0
|
||||
|
||||
for attempt := 1; attempt <= maxAttempts; attempt++ {
|
||||
timeout := baseTimeout + time.Duration(attempt-1)*500*time.Millisecond
|
||||
jitter := time.Duration(rand.Intn(100)) * time.Millisecond
|
||||
timeout += jitter
|
||||
|
||||
latency, err := fn(dst, timeout)
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
logger.Debug("Ping attempt %d/%d failed: %v", attempt, maxAttempts, err)
|
||||
|
||||
if attempt < maxAttempts {
|
||||
backoff := time.Duration(attempt) * 50 * time.Millisecond
|
||||
time.Sleep(backoff)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
totalLatency += latency
|
||||
successCount++
|
||||
return totalLatency / time.Duration(successCount), nil
|
||||
}
|
||||
|
||||
return 0, fmt.Errorf("all %d ping attempts failed, last error: %v", maxAttempts, lastErr)
|
||||
}
|
||||
|
||||
// shouldFireRecovery decides whether the data-plane recovery flow should run on
|
||||
// this tick. See startPingCheck for the rationale behind separating recovery
|
||||
// from the backoff ramp.
|
||||
func shouldFireRecovery(consecutiveFailures, failureThreshold int, connectionLost bool) bool {
|
||||
return consecutiveFailures >= failureThreshold && !connectionLost
|
||||
}
|
||||
|
||||
func (n *Newt) pingWithRetry(fn pingFunc, dst string, timeout time.Duration) (stopChan chan struct{}, err error) {
|
||||
if n.config.HealthFile != "" {
|
||||
err = os.Remove(n.config.HealthFile)
|
||||
if err != nil {
|
||||
logger.Error("Failed to remove health file: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
const (
|
||||
initialRetryDelay = 2 * time.Second
|
||||
maxRetryDelay = 60 * time.Second
|
||||
)
|
||||
|
||||
stopChan = make(chan struct{})
|
||||
attempt := 1
|
||||
retryDelay := initialRetryDelay
|
||||
|
||||
logger.Debug("Ping attempt %d", attempt)
|
||||
if latency, err := fn(dst, timeout); err == nil {
|
||||
logger.Debug("Ping latency: %v", latency)
|
||||
logger.Info("Tunnel connection to server established successfully!")
|
||||
if n.config.HealthFile != "" {
|
||||
if err := os.WriteFile(n.config.HealthFile, []byte("ok"), 0644); err != nil {
|
||||
logger.Warn(msgHealthFileWriteFailed, err)
|
||||
}
|
||||
}
|
||||
return stopChan, nil
|
||||
} else {
|
||||
logger.Warn("Ping attempt %d failed: %v", attempt, err)
|
||||
}
|
||||
|
||||
go func() {
|
||||
attempt = 2
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-stopChan:
|
||||
return
|
||||
default:
|
||||
logger.Debug("Ping attempt %d", attempt)
|
||||
|
||||
if latency, err := fn(dst, timeout); err != nil {
|
||||
logger.Warn("Ping attempt %d failed: %v", attempt, err)
|
||||
|
||||
if attempt%5 == 0 && retryDelay < maxRetryDelay {
|
||||
retryDelay = time.Duration(float64(retryDelay) * 1.5)
|
||||
if retryDelay > maxRetryDelay {
|
||||
retryDelay = maxRetryDelay
|
||||
}
|
||||
logger.Info("Increasing ping retry delay to %v", retryDelay)
|
||||
}
|
||||
|
||||
time.Sleep(retryDelay)
|
||||
attempt++
|
||||
} else {
|
||||
logger.Debug("Ping succeeded after %d attempts", attempt)
|
||||
logger.Debug("Ping latency: %v", latency)
|
||||
logger.Info("Tunnel connection to server established successfully!")
|
||||
if n.config.HealthFile != "" {
|
||||
if err := os.WriteFile(n.config.HealthFile, []byte("ok"), 0644); err != nil {
|
||||
logger.Warn(msgHealthFileWriteFailed, err)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
case <-n.pingStopChan:
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
return stopChan, fmt.Errorf("initial ping attempts failed, continuing in background")
|
||||
}
|
||||
|
||||
func (n *Newt) startPingCheck(fn pingFunc, serverIP, tunnelID string) chan struct{} {
|
||||
maxInterval := 6 * time.Second
|
||||
currentInterval := n.config.PingInterval
|
||||
consecutiveFailures := 0
|
||||
connectionLost := false
|
||||
|
||||
recentLatencies := make([]time.Duration, 0, 10)
|
||||
|
||||
pingStopChan := make(chan struct{})
|
||||
|
||||
go func() {
|
||||
ticker := time.NewTicker(currentInterval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
adaptiveTimeout := n.config.PingTimeout
|
||||
if len(recentLatencies) > 0 {
|
||||
var sum time.Duration
|
||||
for _, lat := range recentLatencies {
|
||||
sum += lat
|
||||
}
|
||||
avgLatency := sum / time.Duration(len(recentLatencies))
|
||||
adaptiveTimeout = avgLatency * 3
|
||||
if adaptiveTimeout < n.config.PingTimeout {
|
||||
adaptiveTimeout = n.config.PingTimeout
|
||||
}
|
||||
if adaptiveTimeout > 15*time.Second {
|
||||
adaptiveTimeout = 15 * time.Second
|
||||
}
|
||||
}
|
||||
|
||||
maxAttempts := 2
|
||||
if consecutiveFailures > 4 {
|
||||
maxAttempts = 4
|
||||
}
|
||||
|
||||
latency, err := reliablePing(fn, serverIP, adaptiveTimeout, maxAttempts)
|
||||
if err != nil {
|
||||
consecutiveFailures++
|
||||
|
||||
recentLatencies = append(recentLatencies, adaptiveTimeout)
|
||||
if len(recentLatencies) > 10 {
|
||||
recentLatencies = recentLatencies[1:]
|
||||
}
|
||||
|
||||
if consecutiveFailures < 2 {
|
||||
logger.Debug("Periodic ping failed (%d consecutive failures): %v", consecutiveFailures, err)
|
||||
} else {
|
||||
logger.Warn("Periodic ping failed (%d consecutive failures): %v", consecutiveFailures, err)
|
||||
}
|
||||
|
||||
failureThreshold := 4
|
||||
if shouldFireRecovery(consecutiveFailures, failureThreshold, connectionLost) {
|
||||
connectionLost = true
|
||||
logger.Warn("Connection to server lost after %d failures. Continuous reconnection attempts will be made.", consecutiveFailures)
|
||||
if tunnelID != "" {
|
||||
telemetry.IncReconnect(context.Background(), tunnelID, "client", telemetry.ReasonTimeout)
|
||||
}
|
||||
pingChainId := generateChainId()
|
||||
n.pendingPingChainId = pingChainId
|
||||
n.stopFunc = n.client.SendMessageInterval("newt/ping/request", map[string]interface{}{
|
||||
"chainId": pingChainId,
|
||||
}, 3*time.Second)
|
||||
bcChainId := generateChainId()
|
||||
n.pendingRegisterChainId = bcChainId
|
||||
if err := n.client.SendMessage("newt/wg/register", map[string]interface{}{
|
||||
"publicKey": n.publicKey.String(),
|
||||
"backwardsCompatible": true,
|
||||
"chainId": bcChainId,
|
||||
}); err != nil {
|
||||
logger.Error("Failed to send registration message: %v", err)
|
||||
}
|
||||
if n.config.HealthFile != "" {
|
||||
if err := os.Remove(n.config.HealthFile); err != nil {
|
||||
logger.Error("Failed to remove health file: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
if consecutiveFailures >= failureThreshold && currentInterval < maxInterval {
|
||||
currentInterval = time.Duration(float64(currentInterval) * 1.3)
|
||||
if currentInterval > maxInterval {
|
||||
currentInterval = maxInterval
|
||||
}
|
||||
}
|
||||
} else {
|
||||
recentLatencies = append(recentLatencies, latency)
|
||||
if tunnelID != "" {
|
||||
telemetry.ObserveTunnelLatency(context.Background(), tunnelID, "wireguard", latency.Seconds())
|
||||
}
|
||||
if len(recentLatencies) > 10 {
|
||||
recentLatencies = recentLatencies[1:]
|
||||
}
|
||||
|
||||
if connectionLost {
|
||||
connectionLost = false
|
||||
logger.Info("Connection to server restored after %d failures!", consecutiveFailures)
|
||||
if n.config.HealthFile != "" {
|
||||
if err := os.WriteFile(n.config.HealthFile, []byte("ok"), 0644); err != nil {
|
||||
logger.Warn("Failed to write health file: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
if currentInterval > n.config.PingInterval {
|
||||
currentInterval = time.Duration(float64(currentInterval) * 0.9)
|
||||
if currentInterval < n.config.PingInterval {
|
||||
currentInterval = n.config.PingInterval
|
||||
}
|
||||
ticker.Reset(currentInterval)
|
||||
logger.Debug("Decreased ping check interval to %v after successful ping", currentInterval)
|
||||
}
|
||||
consecutiveFailures = 0
|
||||
}
|
||||
case <-pingStopChan:
|
||||
logger.Info("Stopping ping check")
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
return pingStopChan
|
||||
}
|
||||
150
newt/targets.go
150
newt/targets.go
@@ -1,150 +0,0 @@
|
||||
package newt
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
||||
"github.com/fosrl/newt/logger"
|
||||
"github.com/fosrl/newt/proxy"
|
||||
)
|
||||
|
||||
func parseTargetData(data interface{}) (TargetData, error) {
|
||||
var targetData TargetData
|
||||
jsonData, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
logger.Info("Error marshaling data: %v", err)
|
||||
return targetData, err
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(jsonData, &targetData); err != nil {
|
||||
logger.Info("Error unmarshaling target data: %v", err)
|
||||
return targetData, err
|
||||
}
|
||||
return targetData, nil
|
||||
}
|
||||
|
||||
// parseTargetString parses "listenPort:host:targetPort", handling IPv6 brackets.
|
||||
func parseTargetString(target string) (int, string, error) {
|
||||
firstColon := strings.Index(target, ":")
|
||||
if firstColon == -1 {
|
||||
return 0, "", fmt.Errorf("invalid target format, no colon found: %s", target)
|
||||
}
|
||||
|
||||
listenPortStr := target[:firstColon]
|
||||
var listenPort int
|
||||
_, err := fmt.Sscanf(listenPortStr, "%d", &listenPort)
|
||||
if err != nil {
|
||||
return 0, "", fmt.Errorf("invalid listen port: %s", listenPortStr)
|
||||
}
|
||||
if listenPort <= 0 || listenPort > 65535 {
|
||||
return 0, "", fmt.Errorf("listen port out of range: %d", listenPort)
|
||||
}
|
||||
|
||||
remainder := target[firstColon+1:]
|
||||
host, targetPort, err := net.SplitHostPort(remainder)
|
||||
if err != nil {
|
||||
return 0, "", fmt.Errorf("invalid host:port format '%s': %w", remainder, err)
|
||||
}
|
||||
|
||||
if host == "" {
|
||||
return 0, "", fmt.Errorf("empty host in target: %s", target)
|
||||
}
|
||||
if targetPort == "" {
|
||||
return 0, "", fmt.Errorf("empty target port in target: %s", target)
|
||||
}
|
||||
|
||||
return listenPort, net.JoinHostPort(host, targetPort), nil
|
||||
}
|
||||
|
||||
func (n *Newt) updateTargets(pm *proxy.ProxyManager, action string, tunnelIP string, proto string, targetData TargetData) error {
|
||||
for _, t := range targetData.Targets {
|
||||
port, target, err := parseTargetString(t)
|
||||
if err != nil {
|
||||
logger.Info("Invalid target format: %s (%v)", t, err)
|
||||
continue
|
||||
}
|
||||
|
||||
switch action {
|
||||
case "add":
|
||||
processedTarget := target
|
||||
if n.config.UpdownScript != "" {
|
||||
newTarget, err := n.executeUpdownScript(action, proto, target)
|
||||
if err != nil {
|
||||
logger.Warn("Updown script error: %v", err)
|
||||
} else if newTarget != "" {
|
||||
processedTarget = newTarget
|
||||
}
|
||||
}
|
||||
|
||||
err := pm.RemoveTarget(proto, tunnelIP, port)
|
||||
if err != nil {
|
||||
if !strings.Contains(err.Error(), "target not found") {
|
||||
logger.Error("Failed to remove existing target: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
pm.AddTarget(proto, tunnelIP, port, processedTarget)
|
||||
|
||||
case "remove":
|
||||
logger.Info("Removing target with port %d", port)
|
||||
|
||||
if n.config.UpdownScript != "" {
|
||||
_, err := n.executeUpdownScript(action, proto, target)
|
||||
if err != nil {
|
||||
logger.Warn("Updown script error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
err = pm.RemoveTarget(proto, tunnelIP, port)
|
||||
if err != nil {
|
||||
logger.Error("Failed to remove target: %v", err)
|
||||
return err
|
||||
}
|
||||
default:
|
||||
logger.Info("Unknown action: %s", action)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (n *Newt) executeUpdownScript(action, proto, target string) (string, error) {
|
||||
if n.config.UpdownScript == "" {
|
||||
return target, nil
|
||||
}
|
||||
|
||||
parts := strings.Fields(n.config.UpdownScript)
|
||||
if len(parts) == 0 {
|
||||
return target, fmt.Errorf("invalid updown script command")
|
||||
}
|
||||
|
||||
var cmd *exec.Cmd
|
||||
if len(parts) == 1 {
|
||||
logger.Info("Executing updown script: %s %s %s %s", n.config.UpdownScript, action, proto, target)
|
||||
cmd = exec.Command(parts[0], action, proto, target)
|
||||
} else {
|
||||
args := append(parts[1:], action, proto, target)
|
||||
logger.Info("Executing updown script: %s %s %s %s %s", parts[0], strings.Join(parts[1:], " "), action, proto, target)
|
||||
cmd = exec.Command(parts[0], args...)
|
||||
}
|
||||
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
if exitErr, ok := err.(*exec.ExitError); ok {
|
||||
return "", fmt.Errorf("updown script execution failed (exit code %d): %s",
|
||||
exitErr.ExitCode(), string(exitErr.Stderr))
|
||||
}
|
||||
return "", fmt.Errorf("updown script execution failed: %v", err)
|
||||
}
|
||||
|
||||
newTarget := strings.TrimSpace(string(output))
|
||||
if newTarget != "" {
|
||||
logger.Info("Updown script returned new target: %s", newTarget)
|
||||
return newTarget, nil
|
||||
}
|
||||
|
||||
return target, nil
|
||||
}
|
||||
109
newt/tunnel.go
109
newt/tunnel.go
@@ -1,109 +0,0 @@
|
||||
package newt
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/fosrl/newt/logger"
|
||||
"github.com/fosrl/newt/network"
|
||||
"github.com/fosrl/newt/util"
|
||||
)
|
||||
|
||||
// updateRemoteExitNodeSubnets replaces the set of active remote exit node
|
||||
// subnets with the given list, updating WireGuard AllowedIPs and native
|
||||
// routes to match.
|
||||
func (n *Newt) updateRemoteExitNodeSubnets(subnets []string) {
|
||||
if n.config.UseNativeMainInterface && len(n.activeRemoteSubnets) > 0 {
|
||||
toRemove := make([]string, 0)
|
||||
newSet := make(map[string]bool, len(subnets))
|
||||
for _, s := range subnets {
|
||||
newSet[s] = true
|
||||
}
|
||||
for _, s := range n.activeRemoteSubnets {
|
||||
if !newSet[s] {
|
||||
toRemove = append(toRemove, s)
|
||||
}
|
||||
}
|
||||
if len(toRemove) > 0 {
|
||||
if err := network.RemoveRoutes(toRemove); err != nil {
|
||||
logger.Warn("Failed to remove old subnet routes: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if n.dev != nil && n.wgData.PublicKey != "" {
|
||||
lines := fmt.Sprintf("public_key=%s\nreplace_allowed_ips=true\nallowed_ip=%s/32",
|
||||
util.FixKey(n.wgData.PublicKey), n.wgData.ServerIP)
|
||||
for _, s := range subnets {
|
||||
lines += "\nallowed_ip=" + s
|
||||
}
|
||||
if err := n.dev.IpcSet(lines); err != nil {
|
||||
logger.Warn("Failed to update WireGuard AllowedIPs: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
if n.config.UseNativeMainInterface && len(subnets) > 0 {
|
||||
existing := make(map[string]bool, len(n.activeRemoteSubnets))
|
||||
for _, s := range n.activeRemoteSubnets {
|
||||
existing[s] = true
|
||||
}
|
||||
toAdd := make([]string, 0)
|
||||
for _, s := range subnets {
|
||||
if !existing[s] {
|
||||
toAdd = append(toAdd, s)
|
||||
}
|
||||
}
|
||||
if len(toAdd) > 0 {
|
||||
if err := network.AddRoutes(toAdd, n.config.NativeMainInterfaceName); err != nil {
|
||||
logger.Warn("Failed to add new subnet routes: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
n.activeRemoteSubnets = append([]string{}, subnets...)
|
||||
logger.Info("Updated remote exit node subnets: %d total", len(subnets))
|
||||
}
|
||||
|
||||
func (n *Newt) closeWgTunnel() {
|
||||
if n.pingStopChan != nil {
|
||||
close(n.pingStopChan)
|
||||
n.pingStopChan = nil
|
||||
}
|
||||
|
||||
if n.browserGatewayStop != nil {
|
||||
n.browserGatewayStop()
|
||||
n.browserGatewayStop = nil
|
||||
n.browserGateway = nil
|
||||
}
|
||||
|
||||
if n.pm != nil {
|
||||
n.pm.Stop()
|
||||
n.currentPM.Store(nil)
|
||||
n.pm = nil
|
||||
}
|
||||
|
||||
if n.config.UseNativeMainInterface {
|
||||
toRemove := make([]string, 0, len(n.activeRemoteSubnets)+1)
|
||||
if n.wgData.ServerIP != "" {
|
||||
toRemove = append(toRemove, n.wgData.ServerIP+"/32")
|
||||
}
|
||||
toRemove = append(toRemove, n.activeRemoteSubnets...)
|
||||
if len(toRemove) > 0 {
|
||||
if err := network.RemoveRoutes(toRemove); err != nil {
|
||||
logger.Warn("Failed to remove native main tunnel routes: %v", err)
|
||||
}
|
||||
}
|
||||
n.activeRemoteSubnets = nil
|
||||
}
|
||||
|
||||
if n.dev != nil {
|
||||
n.dev.Close()
|
||||
n.dev = nil
|
||||
}
|
||||
|
||||
if n.tnet != nil {
|
||||
n.tnet = nil
|
||||
}
|
||||
if n.tun != nil {
|
||||
n.tun = nil
|
||||
}
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
package newt
|
||||
|
||||
import (
|
||||
wgclients "github.com/fosrl/newt/clients"
|
||||
"github.com/fosrl/newt/healthcheck"
|
||||
)
|
||||
|
||||
type BrowserGatewayTarget struct {
|
||||
ID int `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Destination string `json:"destination"`
|
||||
DestinationPort int `json:"destinationPort"`
|
||||
AuthToken string `json:"authToken"`
|
||||
}
|
||||
|
||||
type WgData struct {
|
||||
Endpoint string `json:"endpoint"`
|
||||
RelayPort uint16 `json:"relayPort"`
|
||||
PublicKey string `json:"publicKey"`
|
||||
ServerIP string `json:"serverIP"`
|
||||
TunnelIP string `json:"tunnelIP"`
|
||||
Targets TargetsByType `json:"targets"`
|
||||
HealthCheckTargets []healthcheck.Config `json:"healthCheckTargets"`
|
||||
BrowserGatewayTargets []BrowserGatewayTarget `json:"browserGatewayTargets"`
|
||||
RemoteExitNodeSubnets []string `json:"remoteExitNodeSubnets"`
|
||||
ChainId string `json:"chainId"`
|
||||
}
|
||||
|
||||
type TargetsByType struct {
|
||||
UDP []string `json:"udp"`
|
||||
TCP []string `json:"tcp"`
|
||||
}
|
||||
|
||||
type TargetData struct {
|
||||
Targets []string `json:"targets"`
|
||||
}
|
||||
|
||||
type ExitNodeData struct {
|
||||
ExitNodes []ExitNode `json:"exitNodes"`
|
||||
ChainId string `json:"chainId"`
|
||||
}
|
||||
|
||||
type ExitNode struct {
|
||||
ID int `json:"exitNodeId"`
|
||||
Name string `json:"exitNodeName"`
|
||||
Endpoint string `json:"endpoint"`
|
||||
Weight float64 `json:"weight"`
|
||||
WasPreviouslyConnected bool `json:"wasPreviouslyConnected"`
|
||||
}
|
||||
|
||||
type ExitNodePingResult struct {
|
||||
ExitNodeID int `json:"exitNodeId"`
|
||||
LatencyMs int64 `json:"latencyMs"`
|
||||
Weight float64 `json:"weight"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Name string `json:"exitNodeName"`
|
||||
Endpoint string `json:"endpoint"`
|
||||
WasPreviouslyConnected bool `json:"wasPreviouslyConnected"`
|
||||
}
|
||||
|
||||
type BlueprintResult struct {
|
||||
Success bool `json:"success"`
|
||||
Message string `json:"message,omitempty"`
|
||||
}
|
||||
|
||||
// Define the sync data structure
|
||||
type SyncData struct {
|
||||
Targets TargetsByType `json:"proxyTargets"`
|
||||
HealthCheckTargets []healthcheck.Config `json:"healthCheckTargets"`
|
||||
RemoteExitNodeSubnets []string `json:"remoteExitNodeSubnets"`
|
||||
Peers []wgclients.Peer `json:"peers"`
|
||||
ClientTargets []wgclients.Target `json:"clientTargets"`
|
||||
BrowserGatewayTargets []BrowserGatewayTarget `json:"browserGatewayTargets"`
|
||||
}
|
||||
3
packages/advantech/.gitignore
vendored
3
packages/advantech/.gitignore
vendored
@@ -1,3 +0,0 @@
|
||||
.build
|
||||
*.tgz
|
||||
bin
|
||||
@@ -1,54 +0,0 @@
|
||||
MODNAME := newt
|
||||
VERSION := 1.12.5
|
||||
RELEASE_URL := https://github.com/fosrl/newt/releases/download/$(VERSION)
|
||||
PLATFORM ?= v4
|
||||
|
||||
# Map Advantech platform to newt release binary name
|
||||
arch_v4 := arm64
|
||||
arch_v4i := arm64
|
||||
arch_v3 := arm32
|
||||
arch_v2 := arm32
|
||||
arch_v2i := arm32
|
||||
|
||||
NEWT_ARCH := $(arch_$(PLATFORM))
|
||||
ifeq ($(NEWT_ARCH),)
|
||||
$(error Unknown platform '$(PLATFORM)'. Supported: v4, v4i, v3, v2, v2i)
|
||||
endif
|
||||
|
||||
BINARY := newt_linux_$(NEWT_ARCH)
|
||||
BINDIR := bin
|
||||
OUTFILE := $(MODNAME).$(PLATFORM).tgz
|
||||
STAGEDIR := .build/$(MODNAME)
|
||||
|
||||
.PHONY: all clean
|
||||
|
||||
all: $(OUTFILE)
|
||||
|
||||
# Cache the downloaded binary in bin/ (re-download only if missing)
|
||||
$(BINDIR)/$(BINARY):
|
||||
mkdir -p $(BINDIR)
|
||||
@echo "Downloading $(BINARY) $(VERSION) for platform $(PLATFORM)..."
|
||||
wget -q -O $@ "$(RELEASE_URL)/$(BINARY)" 2>/dev/null || \
|
||||
curl -fsSL -o $@ "$(RELEASE_URL)/$(BINARY)"
|
||||
chmod +x $@
|
||||
@echo "Binary ready: $@"
|
||||
|
||||
# Build the package
|
||||
$(OUTFILE): $(BINDIR)/$(BINARY) $(shell find merge -type f 2>/dev/null)
|
||||
@rm -rf $(STAGEDIR)
|
||||
@mkdir -p $(STAGEDIR)/bin
|
||||
@cp $(BINDIR)/$(BINARY) $(STAGEDIR)/bin/newt
|
||||
@chmod +x $(STAGEDIR)/bin/newt
|
||||
@cp -r merge/. $(STAGEDIR)/
|
||||
@chmod +x \
|
||||
$(STAGEDIR)/etc/init \
|
||||
$(STAGEDIR)/etc/install \
|
||||
$(STAGEDIR)/etc/uninstall \
|
||||
$(STAGEDIR)/www/index.cgi
|
||||
tar -c --owner=0 --group=0 --mtime="2001-01-01 UTC" \
|
||||
-C .build $(MODNAME) | gzip -n > $@
|
||||
@echo "Created: $@"
|
||||
|
||||
clean:
|
||||
rm -rf .build $(MODNAME).*.tgz
|
||||
@echo "Cleaned."
|
||||
@@ -1,61 +0,0 @@
|
||||
MOD_PANGOLIN_SITE_ENABLED=0
|
||||
MOD_PANGOLIN_SITE_ENDPOINT=
|
||||
MOD_PANGOLIN_SITE_ID=
|
||||
MOD_PANGOLIN_SITE_SECRET=
|
||||
|
||||
# Core networking
|
||||
MOD_PANGOLIN_SITE_MTU=
|
||||
MOD_PANGOLIN_SITE_DNS=
|
||||
MOD_PANGOLIN_SITE_INTERFACE=
|
||||
MOD_PANGOLIN_SITE_PORT=
|
||||
MOD_PANGOLIN_SITE_UPDOWN_SCRIPT=
|
||||
MOD_PANGOLIN_SITE_PREFER_ENDPOINT=
|
||||
|
||||
# Logging
|
||||
MOD_PANGOLIN_SITE_LOG_LEVEL=
|
||||
|
||||
# Behavior toggles (true/false)
|
||||
MOD_PANGOLIN_SITE_DISABLE_CLIENTS=
|
||||
MOD_PANGOLIN_SITE_USE_NATIVE_INTERFACE=
|
||||
MOD_PANGOLIN_SITE_ENFORCE_HC_CERT=
|
||||
MOD_PANGOLIN_SITE_NO_CLOUD=
|
||||
|
||||
# Timers
|
||||
MOD_PANGOLIN_SITE_PING_INTERVAL=
|
||||
MOD_PANGOLIN_SITE_PING_TIMEOUT=
|
||||
MOD_PANGOLIN_SITE_UDP_PROXY_IDLE_TIMEOUT=
|
||||
|
||||
# Docker integration
|
||||
MOD_PANGOLIN_SITE_DOCKER_SOCKET=
|
||||
MOD_PANGOLIN_SITE_DOCKER_ENFORCE_NETWORK_VALIDATION=
|
||||
|
||||
# Health / files
|
||||
MOD_PANGOLIN_SITE_HEALTH_FILE=
|
||||
MOD_PANGOLIN_SITE_BLUEPRINT_FILE=
|
||||
MOD_PANGOLIN_SITE_PROVISIONING_BLUEPRINT_FILE=
|
||||
MOD_PANGOLIN_SITE_CONFIG_FILE=
|
||||
|
||||
# Provisioning
|
||||
MOD_PANGOLIN_SITE_PROVISIONING_KEY=
|
||||
MOD_PANGOLIN_SITE_NAME=
|
||||
|
||||
# Auth daemon
|
||||
MOD_PANGOLIN_SITE_AUTH_DAEMON_ENABLED=
|
||||
MOD_PANGOLIN_SITE_AD_GENERATE_RANDOM_PASSWORD=
|
||||
MOD_PANGOLIN_SITE_AD_KEY=
|
||||
MOD_PANGOLIN_SITE_AD_PRINCIPALS_FILE=
|
||||
MOD_PANGOLIN_SITE_AD_CA_CERT_PATH=
|
||||
|
||||
# Metrics / observability
|
||||
MOD_PANGOLIN_SITE_METRICS_PROMETHEUS_ENABLED=
|
||||
MOD_PANGOLIN_SITE_METRICS_OTLP_ENABLED=
|
||||
MOD_PANGOLIN_SITE_ADMIN_ADDR=
|
||||
MOD_PANGOLIN_SITE_REGION=
|
||||
MOD_PANGOLIN_SITE_METRICS_ASYNC_BYTES=
|
||||
MOD_PANGOLIN_SITE_PPROF_ENABLED=
|
||||
|
||||
# mTLS
|
||||
MOD_PANGOLIN_SITE_TLS_CLIENT_CERT=
|
||||
MOD_PANGOLIN_SITE_TLS_CLIENT_KEY=
|
||||
MOD_PANGOLIN_SITE_TLS_CLIENT_CAS=
|
||||
MOD_PANGOLIN_SITE_TLS_CLIENT_CERT_PKCS12=
|
||||
@@ -1,4 +0,0 @@
|
||||
Pangolin Site (newt) is a WireGuard-based tunnel client that connects this router to a
|
||||
Pangolin server, enabling secure remote access to local resources without opening
|
||||
firewall ports. Configure the server endpoint, ID, and secret via the web
|
||||
interface to establish the tunnel automatically on startup.
|
||||
@@ -1,124 +0,0 @@
|
||||
#!/bin/sh
|
||||
|
||||
MODNAME=newt
|
||||
PIDFILE=/tmp/newt.pid
|
||||
LOGFILE=/tmp/newt.log
|
||||
|
||||
set_setting_raw() {
|
||||
key="$1"
|
||||
value="$2"
|
||||
[ -f "/opt/$MODNAME/etc/settings" ] || cp "/opt/$MODNAME/etc/defaults" "/opt/$MODNAME/etc/settings" 2>/dev/null
|
||||
awk -v k="$key" -v v="$value" '
|
||||
BEGIN { done=0 }
|
||||
index($0, k "=") == 1 { print k "=" v; done=1; next }
|
||||
{ print }
|
||||
END { if (!done) print k "=" v }
|
||||
' "/opt/$MODNAME/etc/settings" > "/tmp/${MODNAME}.settings.tmp" && mv "/tmp/${MODNAME}.settings.tmp" "/opt/$MODNAME/etc/settings"
|
||||
}
|
||||
|
||||
/usr/bin/logger -t $MODNAME "DEBUG: $0 $@"
|
||||
|
||||
case "$1" in
|
||||
start)
|
||||
. /opt/$MODNAME/etc/settings
|
||||
if [ "$MOD_PANGOLIN_SITE_ENABLED" != "1" ]; then
|
||||
echo "$MODNAME is disabled in settings, skipping start"
|
||||
exit 0
|
||||
fi
|
||||
if [ -f "$PIDFILE" ] && kill -0 "$(cat "$PIDFILE" 2>/dev/null)" 2>/dev/null; then
|
||||
echo "$MODNAME is already running (PID: $(cat "$PIDFILE"))"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Starting newt implies enable at boot.
|
||||
set_setting_raw "MOD_PANGOLIN_SITE_ENABLED" "1"
|
||||
|
||||
# Map module settings to Newt environment variables.
|
||||
[ -n "$MOD_PANGOLIN_SITE_ENDPOINT" ] && export PANGOLIN_ENDPOINT="$MOD_PANGOLIN_SITE_ENDPOINT"
|
||||
[ -n "$MOD_PANGOLIN_SITE_ID" ] && export NEWT_ID="$MOD_PANGOLIN_SITE_ID"
|
||||
[ -n "$MOD_PANGOLIN_SITE_SECRET" ] && export NEWT_SECRET="$MOD_PANGOLIN_SITE_SECRET"
|
||||
[ -n "$MOD_PANGOLIN_SITE_MTU" ] && export MTU="$MOD_PANGOLIN_SITE_MTU"
|
||||
[ -n "$MOD_PANGOLIN_SITE_DNS" ] && export DNS="$MOD_PANGOLIN_SITE_DNS"
|
||||
[ -n "$MOD_PANGOLIN_SITE_LOG_LEVEL" ] && export LOG_LEVEL="$MOD_PANGOLIN_SITE_LOG_LEVEL"
|
||||
[ -n "$MOD_PANGOLIN_SITE_UPDOWN_SCRIPT" ] && export UPDOWN_SCRIPT="$MOD_PANGOLIN_SITE_UPDOWN_SCRIPT"
|
||||
[ -n "$MOD_PANGOLIN_SITE_INTERFACE" ] && export INTERFACE="$MOD_PANGOLIN_SITE_INTERFACE"
|
||||
[ -n "$MOD_PANGOLIN_SITE_PORT" ] && export PORT="$MOD_PANGOLIN_SITE_PORT"
|
||||
[ -n "$MOD_PANGOLIN_SITE_DISABLE_CLIENTS" ] && export DISABLE_CLIENTS="$MOD_PANGOLIN_SITE_DISABLE_CLIENTS"
|
||||
[ -n "$MOD_PANGOLIN_SITE_USE_NATIVE_INTERFACE" ] && export USE_NATIVE_INTERFACE="$MOD_PANGOLIN_SITE_USE_NATIVE_INTERFACE"
|
||||
[ -n "$MOD_PANGOLIN_SITE_ENFORCE_HC_CERT" ] && export ENFORCE_HC_CERT="$MOD_PANGOLIN_SITE_ENFORCE_HC_CERT"
|
||||
[ -n "$MOD_PANGOLIN_SITE_DOCKER_SOCKET" ] && export DOCKER_SOCKET="$MOD_PANGOLIN_SITE_DOCKER_SOCKET"
|
||||
[ -n "$MOD_PANGOLIN_SITE_PING_INTERVAL" ] && export PING_INTERVAL="$MOD_PANGOLIN_SITE_PING_INTERVAL"
|
||||
[ -n "$MOD_PANGOLIN_SITE_PING_TIMEOUT" ] && export PING_TIMEOUT="$MOD_PANGOLIN_SITE_PING_TIMEOUT"
|
||||
[ -n "$MOD_PANGOLIN_SITE_UDP_PROXY_IDLE_TIMEOUT" ] && export NEWT_UDP_PROXY_IDLE_TIMEOUT="$MOD_PANGOLIN_SITE_UDP_PROXY_IDLE_TIMEOUT"
|
||||
[ -n "$MOD_PANGOLIN_SITE_DOCKER_ENFORCE_NETWORK_VALIDATION" ] && export DOCKER_ENFORCE_NETWORK_VALIDATION="$MOD_PANGOLIN_SITE_DOCKER_ENFORCE_NETWORK_VALIDATION"
|
||||
[ -n "$MOD_PANGOLIN_SITE_HEALTH_FILE" ] && export HEALTH_FILE="$MOD_PANGOLIN_SITE_HEALTH_FILE"
|
||||
[ -n "$MOD_PANGOLIN_SITE_AUTH_DAEMON_ENABLED" ] && export AUTH_DAEMON_ENABLED="$MOD_PANGOLIN_SITE_AUTH_DAEMON_ENABLED"
|
||||
[ -n "$MOD_PANGOLIN_SITE_AD_GENERATE_RANDOM_PASSWORD" ] && export AD_GENERATE_RANDOM_PASSWORD="$MOD_PANGOLIN_SITE_AD_GENERATE_RANDOM_PASSWORD"
|
||||
[ -n "$MOD_PANGOLIN_SITE_AD_KEY" ] && export AD_KEY="$MOD_PANGOLIN_SITE_AD_KEY"
|
||||
[ -n "$MOD_PANGOLIN_SITE_AD_PRINCIPALS_FILE" ] && export AD_PRINCIPALS_FILE="$MOD_PANGOLIN_SITE_AD_PRINCIPALS_FILE"
|
||||
[ -n "$MOD_PANGOLIN_SITE_AD_CA_CERT_PATH" ] && export AD_CA_CERT_PATH="$MOD_PANGOLIN_SITE_AD_CA_CERT_PATH"
|
||||
[ -n "$MOD_PANGOLIN_SITE_METRICS_PROMETHEUS_ENABLED" ] && export NEWT_METRICS_PROMETHEUS_ENABLED="$MOD_PANGOLIN_SITE_METRICS_PROMETHEUS_ENABLED"
|
||||
[ -n "$MOD_PANGOLIN_SITE_METRICS_OTLP_ENABLED" ] && export NEWT_METRICS_OTLP_ENABLED="$MOD_PANGOLIN_SITE_METRICS_OTLP_ENABLED"
|
||||
[ -n "$MOD_PANGOLIN_SITE_ADMIN_ADDR" ] && export NEWT_ADMIN_ADDR="$MOD_PANGOLIN_SITE_ADMIN_ADDR"
|
||||
[ -n "$MOD_PANGOLIN_SITE_REGION" ] && export NEWT_REGION="$MOD_PANGOLIN_SITE_REGION"
|
||||
[ -n "$MOD_PANGOLIN_SITE_METRICS_ASYNC_BYTES" ] && export NEWT_METRICS_ASYNC_BYTES="$MOD_PANGOLIN_SITE_METRICS_ASYNC_BYTES"
|
||||
[ -n "$MOD_PANGOLIN_SITE_PPROF_ENABLED" ] && export NEWT_PPROF_ENABLED="$MOD_PANGOLIN_SITE_PPROF_ENABLED"
|
||||
[ -n "$MOD_PANGOLIN_SITE_TLS_CLIENT_CERT" ] && export TLS_CLIENT_CERT="$MOD_PANGOLIN_SITE_TLS_CLIENT_CERT"
|
||||
[ -n "$MOD_PANGOLIN_SITE_TLS_CLIENT_KEY" ] && export TLS_CLIENT_KEY="$MOD_PANGOLIN_SITE_TLS_CLIENT_KEY"
|
||||
[ -n "$MOD_PANGOLIN_SITE_TLS_CLIENT_CAS" ] && export TLS_CLIENT_CAS="$MOD_PANGOLIN_SITE_TLS_CLIENT_CAS"
|
||||
[ -n "$MOD_PANGOLIN_SITE_TLS_CLIENT_CERT_PKCS12" ] && export TLS_CLIENT_CERT_PKCS12="$MOD_PANGOLIN_SITE_TLS_CLIENT_CERT_PKCS12"
|
||||
[ -n "$MOD_PANGOLIN_SITE_BLUEPRINT_FILE" ] && export BLUEPRINT_FILE="$MOD_PANGOLIN_SITE_BLUEPRINT_FILE"
|
||||
[ -n "$MOD_PANGOLIN_SITE_PROVISIONING_BLUEPRINT_FILE" ] && export PROVISIONING_BLUEPRINT_FILE="$MOD_PANGOLIN_SITE_PROVISIONING_BLUEPRINT_FILE"
|
||||
[ -n "$MOD_PANGOLIN_SITE_NO_CLOUD" ] && export NO_CLOUD="$MOD_PANGOLIN_SITE_NO_CLOUD"
|
||||
[ -n "$MOD_PANGOLIN_SITE_PROVISIONING_KEY" ] && export NEWT_PROVISIONING_KEY="$MOD_PANGOLIN_SITE_PROVISIONING_KEY"
|
||||
[ -n "$MOD_PANGOLIN_SITE_NAME" ] && export NEWT_NAME="$MOD_PANGOLIN_SITE_NAME"
|
||||
[ -n "$MOD_PANGOLIN_SITE_CONFIG_FILE" ] && export CONFIG_FILE="$MOD_PANGOLIN_SITE_CONFIG_FILE"
|
||||
|
||||
export NEWT_SYSTEM_SUBSTRATE="ADVANTECH_ROUTER_APP"
|
||||
export NEWT_PID_FILE="$PIDFILE"
|
||||
|
||||
echo "Starting $MODNAME..."
|
||||
if [ -n "$MOD_PANGOLIN_SITE_PREFER_ENDPOINT" ]; then
|
||||
/opt/$MODNAME/bin/newt --prefer-endpoint "$MOD_PANGOLIN_SITE_PREFER_ENDPOINT" >> "$LOGFILE" 2>&1 &
|
||||
else
|
||||
/opt/$MODNAME/bin/newt >> "$LOGFILE" 2>&1 &
|
||||
fi
|
||||
echo $! > "$PIDFILE"
|
||||
echo "Started $MODNAME (PID: $(cat "$PIDFILE"))"
|
||||
exit 0
|
||||
;;
|
||||
stop)
|
||||
echo "Stopping $MODNAME..."
|
||||
if [ -f "$PIDFILE" ]; then
|
||||
kill "$(cat "$PIDFILE" 2>/dev/null)" 2>/dev/null
|
||||
rm -f "$PIDFILE"
|
||||
fi
|
||||
killall newt 2>/dev/null
|
||||
# Stopping newt implies disable at boot.
|
||||
set_setting_raw "MOD_PANGOLIN_SITE_ENABLED" "0"
|
||||
echo "Stopped $MODNAME"
|
||||
exit 0
|
||||
;;
|
||||
restart)
|
||||
$0 stop
|
||||
sleep 1
|
||||
# Restart should remain enabled after reboot.
|
||||
set_setting_raw "MOD_PANGOLIN_SITE_ENABLED" "1"
|
||||
$0 start
|
||||
;;
|
||||
status)
|
||||
if [ -f "$PIDFILE" ] && kill -0 "$(cat "$PIDFILE" 2>/dev/null)" 2>/dev/null; then
|
||||
echo "$MODNAME is running (PID: $(cat "$PIDFILE"))"
|
||||
exit 0
|
||||
else
|
||||
echo "$MODNAME is not running"
|
||||
exit 1
|
||||
fi
|
||||
;;
|
||||
defaults)
|
||||
cp /opt/$MODNAME/etc/defaults /opt/$MODNAME/etc/settings 2>/dev/null
|
||||
;;
|
||||
*)
|
||||
echo "Usage: $0 {start|stop|restart|status|defaults}"
|
||||
exit 1
|
||||
esac
|
||||
@@ -1,15 +0,0 @@
|
||||
#!/bin/sh
|
||||
|
||||
MODNAME=newt
|
||||
|
||||
# Ensure scripts are executable
|
||||
chmod +x /opt/$MODNAME/etc/init
|
||||
chmod +x /opt/$MODNAME/etc/install
|
||||
chmod +x /opt/$MODNAME/etc/uninstall
|
||||
chmod +x /opt/$MODNAME/bin/newt
|
||||
chmod +x /opt/$MODNAME/www/index.cgi
|
||||
|
||||
# Secure the web interface with router authentication
|
||||
ln -sf /etc/htpasswd /opt/$MODNAME/www/.htpasswd
|
||||
|
||||
exit 0
|
||||
@@ -1 +0,0 @@
|
||||
Pangolin Site
|
||||
@@ -1 +0,0 @@
|
||||
Tunnel client for Pangolin secure remote access.
|
||||
@@ -1,9 +0,0 @@
|
||||
#!/bin/sh
|
||||
|
||||
MODNAME=newt
|
||||
|
||||
rm -f /opt/$MODNAME/www/.htpasswd
|
||||
rm -f /tmp/newt.pid
|
||||
rm -f /tmp/newt.log
|
||||
|
||||
exit 0
|
||||
@@ -1 +0,0 @@
|
||||
1.12.5
|
||||
@@ -1,282 +0,0 @@
|
||||
#!/bin/sh
|
||||
|
||||
MODNAME=newt
|
||||
SETTINGS=/opt/$MODNAME/etc/settings
|
||||
LOGFILE=/tmp/newt.log
|
||||
PIDFILE=/tmp/newt.pid
|
||||
|
||||
# Escape special HTML characters
|
||||
htmlesc() {
|
||||
printf '%s' "$1" | sed \
|
||||
-e 's/&/\&/g' \
|
||||
-e 's/</\</g' \
|
||||
-e 's/>/\>/g' \
|
||||
-e 's/"/\"/g'
|
||||
}
|
||||
|
||||
# Decode URL-encoded form values (handles common chars in endpoints/ids/secrets)
|
||||
urldecode() {
|
||||
printf '%s' "$1" | sed \
|
||||
-e 's/+/ /g' \
|
||||
-e 's/%3[Aa]/:/g' -e 's/%3[aa]/:/g' \
|
||||
-e 's/%2[Ff]/\//g' -e 's/%2[ff]/\//g' \
|
||||
-e 's/%40/@/g' \
|
||||
-e 's/%2[Ee]/./g' \
|
||||
-e 's/%2[Dd]/-/g' \
|
||||
-e 's/%5[Ff]/_/g' \
|
||||
-e 's/%3[Dd]/=/g' \
|
||||
-e 's/%3[Ff]/?/g' \
|
||||
-e 's/%23/#/g' \
|
||||
-e 's/%25/%/g'
|
||||
}
|
||||
|
||||
# Extract a named field from URL-encoded POST data (awk splits on & without tr)
|
||||
get_field() {
|
||||
printf '%s' "$2" | awk -v f="${1}=" 'BEGIN{RS="&"} index($0,f)==1 {print substr($0,length(f)+1); exit}'
|
||||
}
|
||||
|
||||
# Check whether newt process is alive
|
||||
is_running() {
|
||||
[ -f "$PIDFILE" ] && kill -0 "$(cat "$PIDFILE" 2>/dev/null)" 2>/dev/null
|
||||
}
|
||||
|
||||
ensure_settings_file() {
|
||||
[ -f "$SETTINGS" ] && return
|
||||
if [ -f "/opt/$MODNAME/etc/defaults" ]; then
|
||||
cp "/opt/$MODNAME/etc/defaults" "$SETTINGS" 2>/dev/null
|
||||
else
|
||||
printf 'MOD_PANGOLIN_SITE_ENABLED=0\n' > "$SETTINGS"
|
||||
fi
|
||||
}
|
||||
|
||||
set_setting_raw() {
|
||||
key="$1"
|
||||
value="$2"
|
||||
ensure_settings_file
|
||||
awk -v k="$key" -v v="$value" '
|
||||
BEGIN { done=0 }
|
||||
index($0, k "=") == 1 { print k "=" v; done=1; next }
|
||||
{ print }
|
||||
END { if (!done) print k "=" v }
|
||||
' "$SETTINGS" > "$SETTINGS.tmp" && mv "$SETTINGS.tmp" "$SETTINGS"
|
||||
}
|
||||
|
||||
quote_sh_value() {
|
||||
printf '%s' "$1" | sed -e 's/\\/\\\\/g' -e 's/"/\\"/g'
|
||||
}
|
||||
|
||||
# ── Read POST body ──────────────────────────────────────────────────────────
|
||||
POST_DATA=""
|
||||
if [ "$REQUEST_METHOD" = "POST" ] && [ -n "$CONTENT_LENGTH" ] && [ "$CONTENT_LENGTH" -gt 0 ] 2>/dev/null; then
|
||||
POST_DATA=$(dd bs="$CONTENT_LENGTH" count=1 2>/dev/null)
|
||||
fi
|
||||
|
||||
# ── Load current settings ───────────────────────────────────────────────────
|
||||
MOD_PANGOLIN_SITE_ENABLED=0
|
||||
MOD_PANGOLIN_SITE_ENDPOINT=""
|
||||
MOD_PANGOLIN_SITE_ID=""
|
||||
MOD_PANGOLIN_SITE_SECRET=""
|
||||
[ -f "$SETTINGS" ] && . "$SETTINGS"
|
||||
|
||||
# ── Handle actions ──────────────────────────────────────────────────────────
|
||||
if [ -n "$POST_DATA" ]; then
|
||||
ACTION=$(get_field "action" "$POST_DATA")
|
||||
case "$ACTION" in
|
||||
save)
|
||||
EP=$(urldecode "$(get_field "endpoint" "$POST_DATA")")
|
||||
ID=$(urldecode "$(get_field "id" "$POST_DATA")")
|
||||
SEC=$(urldecode "$(get_field "secret" "$POST_DATA")")
|
||||
set_setting_raw "MOD_PANGOLIN_SITE_ENDPOINT" "\"$(quote_sh_value "$EP")\""
|
||||
set_setting_raw "MOD_PANGOLIN_SITE_ID" "\"$(quote_sh_value "$ID")\""
|
||||
set_setting_raw "MOD_PANGOLIN_SITE_SECRET" "\"$(quote_sh_value "$SEC")\""
|
||||
;;
|
||||
start)
|
||||
set_setting_raw "MOD_PANGOLIN_SITE_ENABLED" "1"
|
||||
/opt/$MODNAME/etc/init start >/dev/null 2>&1
|
||||
;;
|
||||
stop)
|
||||
/opt/$MODNAME/etc/init stop >/dev/null 2>&1
|
||||
set_setting_raw "MOD_PANGOLIN_SITE_ENABLED" "0"
|
||||
;;
|
||||
restart)
|
||||
set_setting_raw "MOD_PANGOLIN_SITE_ENABLED" "1"
|
||||
/opt/$MODNAME/etc/init restart >/dev/null 2>&1
|
||||
;;
|
||||
clearlog)
|
||||
printf '' > "$LOGFILE"
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
|
||||
# Reload settings after actions.
|
||||
MOD_PANGOLIN_SITE_ENABLED=0
|
||||
MOD_PANGOLIN_SITE_ENDPOINT=""
|
||||
MOD_PANGOLIN_SITE_ID=""
|
||||
MOD_PANGOLIN_SITE_SECRET=""
|
||||
[ -f "$SETTINGS" ] && . "$SETTINGS"
|
||||
|
||||
# ── Status ──────────────────────────────────────────────────────────────────
|
||||
if is_running; then
|
||||
STATUS_TEXT="Running"
|
||||
STATUS_CLASS="running"
|
||||
DISABLED="disabled"
|
||||
SETTINGS_HINT='<div class="notice">Stop Newt first to edit settings.</div>'
|
||||
else
|
||||
STATUS_TEXT="Stopped"
|
||||
STATUS_CLASS="stopped"
|
||||
DISABLED=""
|
||||
SETTINGS_HINT=""
|
||||
fi
|
||||
|
||||
EP_ESC=$(htmlesc "$MOD_PANGOLIN_SITE_ENDPOINT")
|
||||
ID_ESC=$(htmlesc "$MOD_PANGOLIN_SITE_ID")
|
||||
SEC_ESC=$(htmlesc "$MOD_PANGOLIN_SITE_SECRET")
|
||||
EP_INPUT_ESC="$EP_ESC"
|
||||
[ -z "$MOD_PANGOLIN_SITE_ENDPOINT" ] && EP_INPUT_ESC="https://app.pangolin.net"
|
||||
|
||||
# ── Log (escape HTML and dollar signs to prevent shell expansion in heredoc) ─
|
||||
LOG_HTML=""
|
||||
if [ -f "$LOGFILE" ]; then
|
||||
LOG_HTML=$(tail -100 "$LOGFILE" 2>/dev/null | sed \
|
||||
-e 's/&/\&/g' \
|
||||
-e 's/</\</g' \
|
||||
-e 's/>/\>/g' \
|
||||
-e 's/\$/\$/g')
|
||||
fi
|
||||
|
||||
# ── Output ──────────────────────────────────────────────────────────────────
|
||||
printf 'Content-type: text/html\r\n\r\n'
|
||||
|
||||
# Static head — split around the conditional refresh meta tag
|
||||
cat << 'HEAD_START'
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
HEAD_START
|
||||
|
||||
# Only auto-refresh while newt is running (avoids wiping settings form mid-edit)
|
||||
[ "$STATUS_CLASS" = "running" ] && printf '<meta http-equiv="refresh" content="10">\n'
|
||||
|
||||
cat << 'STATIC_HEAD'
|
||||
<title>Pangolin Site</title>
|
||||
<style>
|
||||
*{box-sizing:border-box}
|
||||
body{margin:0;padding:8px;border-top:3px solid #79BD28;background:#fff;color:#000}
|
||||
body,table,tr,td,a,input,select,textarea,button{font-family:Verdana,Arial,Helvetica,sans-serif;font-size:12px}
|
||||
a{color:#004280;text-decoration:none}
|
||||
a:hover{text-decoration:underline}
|
||||
.topline{display:flex;align-items:center;justify-content:space-between;gap:8px;flex-wrap:wrap;margin-bottom:6px}
|
||||
.app-title{background:#004280;color:#fff;font-weight:bold;padding:6px 8px;border:1px solid #00315f;border-bottom:none}
|
||||
.window{background:#f4f4f4;border:2px solid #004280;padding:0}
|
||||
.section{border-top:1px solid #9fb7d5}
|
||||
.section:first-child{border-top:none}
|
||||
.section-head{background:#c0e0ff;color:#000;font-weight:bold;padding:5px 8px;border-bottom:1px solid #9fb7d5;display:flex;align-items:center;justify-content:space-between;gap:8px;flex-wrap:wrap}
|
||||
.section-body{padding:10px 8px}
|
||||
.badge{display:inline-block;padding:2px 10px;border-radius:2px;color:#fff;font-weight:bold;line-height:1.4}
|
||||
.running{background:#008000}
|
||||
.stopped{background:#800000}
|
||||
.row{display:flex;align-items:center;gap:8px;margin:6px 0}
|
||||
.row label{width:90px;font-weight:bold}
|
||||
.row input{width:360px;max-width:100%;padding:2px 4px;border:1px solid #7f9db9;background:#fff;height:23px}
|
||||
.btn{height:24px;padding:0 10px;border:1px solid #7f9db9;background:#efefef;color:#000;cursor:pointer}
|
||||
.btn:disabled{color:#777;background:#e5e5e5}
|
||||
.btn + .btn{margin-left:6px}
|
||||
.btn-start{border-color:#2d7a2d;background:#dff0df}
|
||||
.btn-stop{border-color:#8a2c2c;background:#f7dddd}
|
||||
.btn-restart{border-color:#9a6a1f;background:#f7ecd8}
|
||||
.btn-save{border-color:#2e5f92;background:#dce9f8}
|
||||
.hint{color:#808080;font-weight:normal}
|
||||
.notice{margin:0 0 10px 0;color:#ff0000;font-style:italic}
|
||||
.log{background:#fff;border:1px solid #7f9db9;color:#000;font-family:monospace;font-size:12px;line-height:1.35;padding:6px;height:300px;overflow-y:auto;white-space:pre-wrap;word-break:break-word}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="topline">
|
||||
<a href="/">« Back to Router</a>
|
||||
</div>
|
||||
<div class="app-title">Router Apps - Pangolin Site</div>
|
||||
<div class="window">
|
||||
STATIC_HEAD
|
||||
|
||||
# Status card (double-quoted heredoc — variables expand)
|
||||
cat << STATUS_CARD
|
||||
<div class="section">
|
||||
<div class="section-head">Status</div>
|
||||
<div class="section-body">
|
||||
<div><span class="badge ${STATUS_CLASS}">${STATUS_TEXT}</span></div>
|
||||
<div style="margin-top:8px">
|
||||
<form method="post" style="display:inline">
|
||||
<input type="hidden" name="action" value="start">
|
||||
<button class="btn btn-start" type="submit">Start</button>
|
||||
</form>
|
||||
<form method="post" style="display:inline">
|
||||
<input type="hidden" name="action" value="stop">
|
||||
<button class="btn btn-stop" type="submit">Stop</button>
|
||||
</form>
|
||||
<form method="post" style="display:inline">
|
||||
<input type="hidden" name="action" value="restart">
|
||||
<button class="btn btn-restart" type="submit">Restart</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
STATUS_CARD
|
||||
|
||||
# Settings card
|
||||
cat << SETTINGS_CARD
|
||||
<div class="section">
|
||||
<div class="section-head">Settings</div>
|
||||
<div class="section-body">
|
||||
${SETTINGS_HINT}<form method="post">
|
||||
<input type="hidden" name="action" value="save">
|
||||
<div class="row">
|
||||
<label>Endpoint</label>
|
||||
<input type="text" name="endpoint" value="${EP_INPUT_ESC}" ${DISABLED}>
|
||||
</div>
|
||||
<div class="row">
|
||||
<label>ID</label>
|
||||
<input type="text" name="id" value="${ID_ESC}" ${DISABLED}>
|
||||
</div>
|
||||
<div class="row">
|
||||
<label>Secret</label>
|
||||
<input type="password" name="secret" value="${SEC_ESC}" ${DISABLED}>
|
||||
</div>
|
||||
<div style="margin-top:12px">
|
||||
<button class="btn btn-save" type="submit" ${DISABLED}>Save</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
SETTINGS_CARD
|
||||
|
||||
# Log card header
|
||||
cat << 'LOG_HEADER'
|
||||
<div class="section">
|
||||
<div class="section-head">
|
||||
<span>Log <span class="hint">(last 100 lines, auto-refreshes every 10s while running)</span></span>
|
||||
<span style="display:inline-flex;gap:6px">
|
||||
<form method="post" style="margin:0">
|
||||
<input type="hidden" name="action" value="clearlog">
|
||||
<button class="btn" type="submit">Clear Log</button>
|
||||
</form>
|
||||
<form method="get" style="margin:0">
|
||||
<button class="btn" type="submit">Refresh</button>
|
||||
</form>
|
||||
</span>
|
||||
</div>
|
||||
<div class="section-body">
|
||||
<div class="log">
|
||||
LOG_HEADER
|
||||
|
||||
# Log content — printed with printf to prevent any shell expansion
|
||||
printf '%s' "$LOG_HTML"
|
||||
|
||||
# Close tags (static)
|
||||
cat << 'STATIC_FOOTER'
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
STATIC_FOOTER
|
||||
@@ -1,30 +0,0 @@
|
||||
# Maintainer: Fossorial <hello@fossorial.io>
|
||||
pkgname=newt
|
||||
pkgver=1.13.0
|
||||
pkgrel=1
|
||||
pkgdesc="Fully user space WireGuard tunnel client and TCP/UDP proxy for Pangolin"
|
||||
arch=('x86_64' 'aarch64' 'armv7h' 'armv6h' 'riscv64')
|
||||
url="https://github.com/fosrl/newt"
|
||||
license=('AGPL3')
|
||||
makedepends=('go')
|
||||
source=("$pkgname-$pkgver.tar.gz::https://github.com/fosrl/newt/archive/refs/tags/v$pkgver.tar.gz")
|
||||
sha256sums=('SKIP')
|
||||
|
||||
build() {
|
||||
cd "$pkgname-$pkgver"
|
||||
export CGO_ENABLED=0
|
||||
export GOFLAGS="-trimpath -mod=readonly -modcacherw"
|
||||
go build -ldflags "-X main.newtVersion=$pkgver -X main.newtPlatform=linux_$(go env GOARCH)" -o "$pkgname" .
|
||||
}
|
||||
|
||||
check() {
|
||||
cd "$pkgname-$pkgver"
|
||||
go test ./... || true
|
||||
}
|
||||
|
||||
package() {
|
||||
cd "$pkgname-$pkgver"
|
||||
install -Dm755 "$pkgname" "$pkgdir/usr/bin/$pkgname"
|
||||
install -Dm644 LICENSE "$pkgdir/usr/share/licenses/$pkgname/LICENSE"
|
||||
install -Dm644 README.md "$pkgdir/usr/share/doc/$pkgname/README.md"
|
||||
}
|
||||
214
proxy/manager.go
214
proxy/manager.go
@@ -18,6 +18,7 @@ import (
|
||||
"go.opentelemetry.io/otel/attribute"
|
||||
"go.opentelemetry.io/otel/metric"
|
||||
"golang.zx2c4.com/wireguard/tun/netstack"
|
||||
"gvisor.dev/gvisor/pkg/tcpip/adapters/gonet"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -53,60 +54,15 @@ type Target struct {
|
||||
Port int
|
||||
}
|
||||
|
||||
// managedListener wraps a net.Listener so an intentional Close() can be
|
||||
// detected reliably by the accept loop. gVisor's netstack (unlike the
|
||||
// stdlib) does not return net.ErrClosed from Accept() after Close() - it
|
||||
// returns a generic "endpoint is in invalid state" error - so relying on
|
||||
// errors.Is(err, net.ErrClosed) leaves the accept loop spinning forever.
|
||||
type managedListener struct {
|
||||
net.Listener
|
||||
closed chan struct{}
|
||||
}
|
||||
|
||||
func newManagedListener(l net.Listener) *managedListener {
|
||||
return &managedListener{Listener: l, closed: make(chan struct{})}
|
||||
}
|
||||
|
||||
func (m *managedListener) Close() error {
|
||||
err := m.Listener.Close()
|
||||
select {
|
||||
case <-m.closed:
|
||||
default:
|
||||
close(m.closed)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// managedPacketConn is the net.PacketConn equivalent of managedListener.
|
||||
type managedPacketConn struct {
|
||||
net.PacketConn
|
||||
closed chan struct{}
|
||||
}
|
||||
|
||||
func newManagedPacketConn(c net.PacketConn) *managedPacketConn {
|
||||
return &managedPacketConn{PacketConn: c, closed: make(chan struct{})}
|
||||
}
|
||||
|
||||
func (m *managedPacketConn) Close() error {
|
||||
err := m.PacketConn.Close()
|
||||
select {
|
||||
case <-m.closed:
|
||||
default:
|
||||
close(m.closed)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// ProxyManager handles the creation and management of proxy connections
|
||||
type ProxyManager struct {
|
||||
tnet *netstack.Net
|
||||
tcpTargets map[string]map[int]string // map[listenIP]map[port]targetAddress
|
||||
udpTargets map[string]map[int]string
|
||||
listeners []net.Listener
|
||||
udpConns []net.PacketConn
|
||||
running bool
|
||||
mutex sync.RWMutex
|
||||
nativeListenIP string // when non-empty, use native OS listeners instead of netstack
|
||||
tnet *netstack.Net
|
||||
tcpTargets map[string]map[int]string // map[listenIP]map[port]targetAddress
|
||||
udpTargets map[string]map[int]string
|
||||
listeners []*gonet.TCPListener
|
||||
udpConns []*gonet.UDPConn
|
||||
running bool
|
||||
mutex sync.RWMutex
|
||||
|
||||
// telemetry (multi-tunnel)
|
||||
currentTunnelID string
|
||||
@@ -114,9 +70,6 @@ type ProxyManager struct {
|
||||
asyncBytes bool
|
||||
flushStop chan struct{}
|
||||
udpIdleTimeout time.Duration
|
||||
|
||||
// connection blocking
|
||||
blocked atomic.Bool
|
||||
}
|
||||
|
||||
// tunnelEntry holds per-tunnel attributes and (optional) async counters.
|
||||
@@ -193,29 +146,15 @@ func classifyProxyError(err error) string {
|
||||
}
|
||||
}
|
||||
|
||||
// NewProxyManager creates a new proxy manager instance backed by a netstack.
|
||||
// NewProxyManager creates a new proxy manager instance
|
||||
func NewProxyManager(tnet *netstack.Net) *ProxyManager {
|
||||
return &ProxyManager{
|
||||
tnet: tnet,
|
||||
tcpTargets: make(map[string]map[int]string),
|
||||
udpTargets: make(map[string]map[int]string),
|
||||
listeners: make([]net.Listener, 0),
|
||||
udpConns: make([]net.PacketConn, 0),
|
||||
tunnels: make(map[string]*tunnelEntry),
|
||||
udpIdleTimeout: defaultUDPIdleTimeout,
|
||||
}
|
||||
}
|
||||
|
||||
// NewProxyManagerNative creates a proxy manager that binds listeners directly
|
||||
// to the host network stack on the given IP address.
|
||||
func NewProxyManagerNative(listenIP string) *ProxyManager {
|
||||
return &ProxyManager{
|
||||
nativeListenIP: listenIP,
|
||||
tcpTargets: make(map[string]map[int]string),
|
||||
udpTargets: make(map[string]map[int]string),
|
||||
listeners: make([]net.Listener, 0),
|
||||
udpConns: make([]net.PacketConn, 0),
|
||||
tunnels: make(map[string]*tunnelEntry),
|
||||
tnet: tnet,
|
||||
tcpTargets: make(map[string]map[int]string),
|
||||
udpTargets: make(map[string]map[int]string),
|
||||
listeners: make([]*gonet.TCPListener, 0),
|
||||
udpConns: make([]*gonet.UDPConn, 0),
|
||||
tunnels: make(map[string]*tunnelEntry),
|
||||
udpIdleTimeout: defaultUDPIdleTimeout,
|
||||
}
|
||||
}
|
||||
@@ -287,14 +226,13 @@ func (pm *ProxyManager) ClearTunnelID() {
|
||||
pm.currentTunnelID = ""
|
||||
}
|
||||
|
||||
// NewProxyManagerWithoutTNet creates a proxy manager with no backing network.
|
||||
// Call SetTNet before starting.
|
||||
// init function without tnet
|
||||
func NewProxyManagerWithoutTNet() *ProxyManager {
|
||||
return &ProxyManager{
|
||||
tcpTargets: make(map[string]map[int]string),
|
||||
udpTargets: make(map[string]map[int]string),
|
||||
listeners: make([]net.Listener, 0),
|
||||
udpConns: make([]net.PacketConn, 0),
|
||||
tcpTargets: make(map[string]map[int]string),
|
||||
udpTargets: make(map[string]map[int]string),
|
||||
listeners: make([]*gonet.TCPListener, 0),
|
||||
udpConns: make([]*gonet.UDPConn, 0),
|
||||
udpIdleTimeout: defaultUDPIdleTimeout,
|
||||
}
|
||||
}
|
||||
@@ -306,18 +244,6 @@ func (pm *ProxyManager) SetTNet(tnet *netstack.Net) {
|
||||
pm.tnet = tnet
|
||||
}
|
||||
|
||||
// SetBlocked enables or disables connection blocking.
|
||||
// When enabled, all new incoming TCP connections are immediately closed
|
||||
// and all incoming UDP packets are silently dropped.
|
||||
func (pm *ProxyManager) SetBlocked(v bool) {
|
||||
pm.blocked.Store(v)
|
||||
if v {
|
||||
logger.Debug("ProxyManager: connection blocking enabled, new connections will be dropped")
|
||||
} else {
|
||||
logger.Debug("ProxyManager: connection blocking disabled, accepting connections")
|
||||
}
|
||||
}
|
||||
|
||||
// AddTarget adds as new target for proxying
|
||||
func (pm *ProxyManager) AddTarget(proto, listenIP string, port int, targetAddr string) error {
|
||||
pm.mutex.Lock()
|
||||
@@ -555,46 +481,23 @@ func (pm *ProxyManager) Stop() error {
|
||||
func (pm *ProxyManager) startTarget(proto, listenIP string, port int, targetAddr string) error {
|
||||
switch proto {
|
||||
case "tcp":
|
||||
var listener net.Listener
|
||||
if pm.tnet != nil {
|
||||
l, err := pm.tnet.ListenTCP(&net.TCPAddr{Port: port})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create TCP listener: %v", err)
|
||||
}
|
||||
listener = l
|
||||
} else if pm.nativeListenIP != "" {
|
||||
l, err := net.ListenTCP("tcp", &net.TCPAddr{IP: net.ParseIP(pm.nativeListenIP), Port: port})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create native TCP listener on %s:%d: %v", pm.nativeListenIP, port, err)
|
||||
}
|
||||
listener = l
|
||||
} else {
|
||||
return fmt.Errorf("proxy manager has no tnet or native IP configured")
|
||||
listener, err := pm.tnet.ListenTCP(&net.TCPAddr{Port: port})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create TCP listener: %v", err)
|
||||
}
|
||||
ml := newManagedListener(listener)
|
||||
pm.listeners = append(pm.listeners, ml)
|
||||
go pm.handleTCPProxy(ml, targetAddr)
|
||||
|
||||
pm.listeners = append(pm.listeners, listener)
|
||||
go pm.handleTCPProxy(listener, targetAddr)
|
||||
|
||||
case "udp":
|
||||
var conn net.PacketConn
|
||||
if pm.tnet != nil {
|
||||
c, err := pm.tnet.ListenUDP(&net.UDPAddr{Port: port})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create UDP listener: %v", err)
|
||||
}
|
||||
conn = c
|
||||
} else if pm.nativeListenIP != "" {
|
||||
c, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.ParseIP(pm.nativeListenIP), Port: port})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create native UDP listener on %s:%d: %v", pm.nativeListenIP, port, err)
|
||||
}
|
||||
conn = c
|
||||
} else {
|
||||
return fmt.Errorf("proxy manager has no tnet or native IP configured")
|
||||
addr := &net.UDPAddr{Port: port}
|
||||
conn, err := pm.tnet.ListenUDP(addr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create UDP listener: %v", err)
|
||||
}
|
||||
mc := newManagedPacketConn(conn)
|
||||
pm.udpConns = append(pm.udpConns, mc)
|
||||
go pm.handleUDPProxy(mc, targetAddr)
|
||||
|
||||
pm.udpConns = append(pm.udpConns, conn)
|
||||
go pm.handleUDPProxy(conn, targetAddr)
|
||||
|
||||
default:
|
||||
return fmt.Errorf(errUnsupportedProtoFmt, proto)
|
||||
@@ -614,17 +517,11 @@ func (pm *ProxyManager) getEntry(id string) *tunnelEntry {
|
||||
return e
|
||||
}
|
||||
|
||||
func (pm *ProxyManager) handleTCPProxy(listener *managedListener, targetAddr string) {
|
||||
func (pm *ProxyManager) handleTCPProxy(listener net.Listener, targetAddr string) {
|
||||
for {
|
||||
conn, err := listener.Accept()
|
||||
if err != nil {
|
||||
telemetry.IncProxyAccept(context.Background(), pm.currentTunnelID, "tcp", "failure", classifyProxyError(err))
|
||||
select {
|
||||
case <-listener.closed:
|
||||
logger.Info("TCP listener closed, stopping proxy handler for %v", listener.Addr())
|
||||
return
|
||||
default:
|
||||
}
|
||||
if !pm.running {
|
||||
return
|
||||
}
|
||||
@@ -638,12 +535,6 @@ func (pm *ProxyManager) handleTCPProxy(listener *managedListener, targetAddr str
|
||||
}
|
||||
|
||||
tunnelID := pm.currentTunnelID
|
||||
// Drop connection if blocking is enabled
|
||||
if pm.blocked.Load() {
|
||||
conn.Close()
|
||||
logger.Debug("TCP proxy: connection dropped (blocking enabled)")
|
||||
continue
|
||||
}
|
||||
telemetry.IncProxyAccept(context.Background(), tunnelID, "tcp", "success", "")
|
||||
telemetry.IncProxyConnectionEvent(context.Background(), tunnelID, "tcp", telemetry.ProxyConnectionOpened)
|
||||
if tunnelID != "" {
|
||||
@@ -699,7 +590,7 @@ func (pm *ProxyManager) handleTCPProxy(listener *managedListener, targetAddr str
|
||||
}
|
||||
}
|
||||
|
||||
func (pm *ProxyManager) handleUDPProxy(conn *managedPacketConn, targetAddr string) {
|
||||
func (pm *ProxyManager) handleUDPProxy(conn *gonet.UDPConn, targetAddr string) {
|
||||
bufPtr := getUDPBuffer()
|
||||
defer putUDPBuffer(bufPtr)
|
||||
buffer := *bufPtr
|
||||
@@ -709,50 +600,37 @@ func (pm *ProxyManager) handleUDPProxy(conn *managedPacketConn, targetAddr strin
|
||||
for {
|
||||
n, remoteAddr, err := conn.ReadFrom(buffer)
|
||||
if err != nil {
|
||||
closeAllClients := func() {
|
||||
if !pm.running {
|
||||
// Clean up all connections when stopping
|
||||
clientsMutex.Lock()
|
||||
for _, targetConn := range clientConns {
|
||||
targetConn.Close()
|
||||
}
|
||||
clientConns = nil
|
||||
clientsMutex.Unlock()
|
||||
}
|
||||
|
||||
// Check for intentional closure first: netstack does not
|
||||
// surface net.ErrClosed/io.EOF from ReadFrom() after Close(),
|
||||
// so this channel is the only reliable signal.
|
||||
select {
|
||||
case <-conn.closed:
|
||||
logger.Info("UDP connection closed, stopping proxy handler")
|
||||
closeAllClients()
|
||||
return
|
||||
default:
|
||||
}
|
||||
|
||||
if !pm.running {
|
||||
closeAllClients()
|
||||
return
|
||||
}
|
||||
|
||||
// Check for connection closed conditions
|
||||
if errors.Is(err, io.EOF) || errors.Is(err, net.ErrClosed) {
|
||||
logger.Info("UDP connection closed, stopping proxy handler")
|
||||
closeAllClients()
|
||||
|
||||
// Clean up existing client connections
|
||||
clientsMutex.Lock()
|
||||
for _, targetConn := range clientConns {
|
||||
targetConn.Close()
|
||||
}
|
||||
clientConns = nil
|
||||
clientsMutex.Unlock()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
logger.Error("Error reading UDP packet: %v", err)
|
||||
// Avoid a tight busy-loop if this error persists.
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
continue
|
||||
}
|
||||
|
||||
clientKey := remoteAddr.String()
|
||||
// Drop packet if blocking is enabled
|
||||
if pm.blocked.Load() {
|
||||
logger.Debug("UDP proxy: packet dropped (blocking enabled)")
|
||||
continue
|
||||
}
|
||||
// bytes from client -> target (direction=in)
|
||||
if pm.currentTunnelID != "" && n > 0 {
|
||||
if pm.asyncBytes {
|
||||
|
||||
@@ -1,87 +0,0 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/netip"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/fosrl/newt/internal/telemetry"
|
||||
"github.com/fosrl/newt/logger"
|
||||
"golang.zx2c4.com/wireguard/tun/netstack"
|
||||
)
|
||||
|
||||
// TestRemoveTargetStopsAcceptLoop verifies that removing a TCP target on a
|
||||
// netstack-backed ProxyManager causes the accept loop goroutine to actually
|
||||
// stop retrying, instead of spinning forever logging
|
||||
// "Error accepting TCP connection: ... endpoint is in invalid state".
|
||||
func TestRemoveTargetStopsAcceptLoop(t *testing.T) {
|
||||
if _, err := telemetry.Init(context.Background(), telemetry.Config{ServiceName: "test"}); err != nil {
|
||||
t.Fatalf("telemetry.Init: %v", err)
|
||||
}
|
||||
|
||||
logFile, err := os.CreateTemp(t.TempDir(), "newt-proxy-test-*.log")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateTemp: %v", err)
|
||||
}
|
||||
defer logFile.Close()
|
||||
logger.SetOutput(logFile)
|
||||
defer logger.SetOutput(os.Stdout)
|
||||
|
||||
_, tnet, err := netstack.CreateNetTUN(
|
||||
[]netip.Addr{netip.MustParseAddr("100.64.0.1")},
|
||||
[]netip.Addr{},
|
||||
1420,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateNetTUN: %v", err)
|
||||
}
|
||||
|
||||
pm := NewProxyManager(tnet)
|
||||
const listenIP = "100.64.0.1"
|
||||
const port = 53405
|
||||
|
||||
if err := pm.AddTarget("tcp", listenIP, port, "127.0.0.1:9999"); err != nil {
|
||||
t.Fatalf("AddTarget: %v", err)
|
||||
}
|
||||
if err := pm.Start(); err != nil {
|
||||
t.Fatalf("Start: %v", err)
|
||||
}
|
||||
|
||||
if err := pm.RemoveTarget("tcp", listenIP, port); err != nil {
|
||||
t.Fatalf("RemoveTarget: %v", err)
|
||||
}
|
||||
|
||||
// If the bug is present, the accept loop spins every 100ms logging an
|
||||
// error forever. Sample the log twice, 400ms apart; a healthy accept
|
||||
// loop logs the error/close message once (or zero times) and then goes
|
||||
// silent, while the buggy loop keeps appending.
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
countAt1 := countAcceptErrors(t, logFile.Name())
|
||||
|
||||
time.Sleep(400 * time.Millisecond)
|
||||
countAt2 := countAcceptErrors(t, logFile.Name())
|
||||
|
||||
t.Logf("accept-error-ish log lines: at 200ms=%d, at 600ms=%d", countAt1, countAt2)
|
||||
|
||||
if countAt2 > countAt1 {
|
||||
t.Fatalf("accept loop kept logging after RemoveTarget (200ms=%d, 600ms=%d) -- it is spinning forever on the closed netstack listener instead of exiting", countAt1, countAt2)
|
||||
}
|
||||
}
|
||||
|
||||
func countAcceptErrors(t *testing.T, path string) int {
|
||||
t.Helper()
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile: %v", err)
|
||||
}
|
||||
count := 0
|
||||
for _, line := range strings.Split(string(data), "\n") {
|
||||
if strings.Contains(line, "Error accepting TCP connection") {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
//go:build !windows
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
// reexec replaces the current process image with a fresh copy of itself,
|
||||
// preserving all arguments and environment variables. On success it never
|
||||
// returns (execve replaces the process in-place). On failure it returns an
|
||||
// error describing why the exec could not be performed.
|
||||
func reexec() error {
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get executable path: %w", err)
|
||||
}
|
||||
return syscall.Exec(exe, os.Args, os.Environ())
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
//go:build windows
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
)
|
||||
|
||||
// reexec restarts newt. On Windows, execve is not available, so outside of
|
||||
// service mode we start a child process and exit (on success this never
|
||||
// returns since os.Exit terminates the current process).
|
||||
//
|
||||
// When running as a Windows service, spawning a detached child would orphan
|
||||
// it from the Service Control Manager (the SCM only tracks processes it
|
||||
// started itself), and os.Exit would end the process without ever reporting
|
||||
// a clean status transition, making the service look like it crashed. So in
|
||||
// that case we instead delegate to requestServiceRestart, which asks the SCM
|
||||
// itself to relaunch the service.
|
||||
func reexec() error {
|
||||
if isWindowsService() {
|
||||
return requestServiceRestart()
|
||||
}
|
||||
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get executable path: %w", err)
|
||||
}
|
||||
cmd := exec.Command(exe, os.Args[1:]...)
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
cmd.Stdin = os.Stdin
|
||||
cmd.Env = os.Environ()
|
||||
if err := cmd.Start(); err != nil {
|
||||
return fmt.Errorf("failed to start new process: %w", err)
|
||||
}
|
||||
os.Exit(0)
|
||||
return nil // unreachable
|
||||
}
|
||||
@@ -7,11 +7,11 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
@@ -86,44 +86,10 @@ type newtService struct {
|
||||
args []string
|
||||
}
|
||||
|
||||
// activeService points at the newtService currently running under the SCM,
|
||||
// so that requestServiceRestart (called from deep inside the app via
|
||||
// cfg.OnRestart) can signal it without threading a reference through the
|
||||
// newt package. restartRequested tells Execute's completion path whether the
|
||||
// tunnel shut down because of a real Stop/Shutdown control request (report
|
||||
// success) or because the app asked to be restarted (report a non-zero exit
|
||||
// code so the SCM's configured recovery action relaunches the service as a
|
||||
// fresh, SCM-tracked process).
|
||||
var (
|
||||
activeService *newtService
|
||||
restartRequested atomic.Bool
|
||||
)
|
||||
|
||||
// requestServiceRestart asks the Windows Service Control Manager to relaunch
|
||||
// the service. Unlike reexec on other platforms, a Windows service cannot
|
||||
// replace its own process image or spawn a detached child that the SCM would
|
||||
// recognize - the SCM only respawns processes it started itself. So instead
|
||||
// we gracefully stop the current run (same as a real Stop control request)
|
||||
// and report a failure exit code on the way out, which - combined with the
|
||||
// recovery actions configured in configureRecoveryActions - causes the SCM
|
||||
// to start a brand new, properly tracked instance of the service.
|
||||
func requestServiceRestart() error {
|
||||
if activeService == nil || activeService.stop == nil {
|
||||
return fmt.Errorf("newt service is not running")
|
||||
}
|
||||
restartRequested.Store(true)
|
||||
activeService.elog.Info(1, "Restart requested; stopping tunnel and asking the service control manager to relaunch")
|
||||
activeService.stop()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *newtService) Execute(args []string, r <-chan svc.ChangeRequest, changes chan<- svc.Status) (bool, uint32) {
|
||||
const cmdsAccepted = svc.AcceptStop | svc.AcceptShutdown
|
||||
changes <- svc.Status{State: svc.StartPending}
|
||||
|
||||
activeService = s
|
||||
restartRequested.Store(false)
|
||||
|
||||
s.elog.Info(1, fmt.Sprintf("Service Execute called with args: %v", args))
|
||||
|
||||
// Load saved service arguments
|
||||
@@ -204,12 +170,8 @@ func (s *newtService) Execute(args []string, r <-chan svc.ChangeRequest, changes
|
||||
s.elog.Error(1, fmt.Sprintf("Unexpected control request #%d", c))
|
||||
}
|
||||
case <-newtDone:
|
||||
changes <- svc.Status{State: svc.StopPending}
|
||||
if restartRequested.Load() {
|
||||
s.elog.Info(1, "Main newt logic stopped for restart, reporting failure exit code so the SCM relaunches the service")
|
||||
return false, 1
|
||||
}
|
||||
s.elog.Info(1, "Main newt logic completed, stopping service")
|
||||
changes <- svc.Status{State: svc.StopPending}
|
||||
return false, 0
|
||||
}
|
||||
}
|
||||
@@ -246,52 +208,6 @@ func (s *newtService) runNewt() {
|
||||
}
|
||||
}
|
||||
|
||||
// configureRecoveryActions tells the Service Control Manager to relaunch the
|
||||
// service automatically when it stops with a non-zero exit code. This is
|
||||
// what makes requestServiceRestart's approach (report failure, let the SCM
|
||||
// respawn us) actually result in a restart.
|
||||
func configureRecoveryActions(s *mgr.Service) error {
|
||||
actions := []mgr.RecoveryAction{
|
||||
{Type: mgr.ServiceRestart, Delay: 5 * time.Second},
|
||||
{Type: mgr.ServiceRestart, Delay: 5 * time.Second},
|
||||
{Type: mgr.ServiceRestart, Delay: 30 * time.Second},
|
||||
}
|
||||
if err := s.SetRecoveryActions(actions, 24*60*60); err != nil {
|
||||
return fmt.Errorf("failed to set recovery actions: %v", err)
|
||||
}
|
||||
// By default the SCM only runs recovery actions when a service crashes.
|
||||
// A restart-via-OnRestart is a controlled stop that merely reports a
|
||||
// non-zero exit code, so this flag must be enabled for it to count.
|
||||
if err := s.SetRecoveryActionsOnNonCrashFailures(true); err != nil {
|
||||
return fmt.Errorf("failed to enable recovery actions on non-crash failures: %v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ensureRecoveryActionsConfigured is a best-effort check run at service
|
||||
// startup so that services installed by older versions of newt (before
|
||||
// recovery actions existed) get them configured on their next start, without
|
||||
// requiring a reinstall.
|
||||
func ensureRecoveryActionsConfigured(name string, elog debug.Log) {
|
||||
m, err := mgr.Connect()
|
||||
if err != nil {
|
||||
elog.Warning(1, fmt.Sprintf("Could not connect to service manager to verify recovery actions: %v", err))
|
||||
return
|
||||
}
|
||||
defer m.Disconnect()
|
||||
|
||||
s, err := m.OpenService(name)
|
||||
if err != nil {
|
||||
elog.Warning(1, fmt.Sprintf("Could not open service to verify recovery actions: %v", err))
|
||||
return
|
||||
}
|
||||
defer s.Close()
|
||||
|
||||
if err := configureRecoveryActions(s); err != nil {
|
||||
elog.Warning(1, fmt.Sprintf("Could not configure recovery actions: %v", err))
|
||||
}
|
||||
}
|
||||
|
||||
func runService(name string, isDebug bool, args []string) {
|
||||
var err error
|
||||
var elog debug.Log
|
||||
@@ -309,7 +225,6 @@ func runService(name string, isDebug bool, args []string) {
|
||||
defer elog.Close()
|
||||
|
||||
elog.Info(1, fmt.Sprintf("Starting %s service", name))
|
||||
ensureRecoveryActionsConfigured(name, elog)
|
||||
run := svc.Run
|
||||
if isDebug {
|
||||
run = debug.Run
|
||||
@@ -369,13 +284,6 @@ func installService() error {
|
||||
return fmt.Errorf("failed to install event log: %v", err)
|
||||
}
|
||||
|
||||
if err := configureRecoveryActions(s); err != nil {
|
||||
// Non-fatal: the service is installed and usable, it just won't
|
||||
// auto-relaunch on an app-initiated restart until this is retried
|
||||
// (ensureRecoveryActionsConfigured does so on every service start).
|
||||
fmt.Printf("Warning: failed to configure service recovery actions: %v\n", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -741,7 +649,7 @@ func setupWindowsEventLog() {
|
||||
// Set the custom logger output
|
||||
logger.GetLogger().SetOutput(file)
|
||||
|
||||
logger.Debug("Newt service logging initialized - log file: %s", logFile)
|
||||
log.Printf("Newt service logging initialized - log file: %s", logFile)
|
||||
}
|
||||
|
||||
// handleServiceCommand checks for service management commands and returns true if handled
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
//go:build !windows
|
||||
|
||||
package updates
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/fosrl/newt/logger"
|
||||
)
|
||||
|
||||
const advantechVersionFile = "/opt/newt/etc/version"
|
||||
|
||||
// postUpdateAdvantech performs Advantech Router App-specific post-update steps:
|
||||
// - Writes the new version string to /opt/newt/etc/version so that the
|
||||
// router firmware's package management reflects the installed version.
|
||||
// - Updates the PID file at pidFile (if non-empty) with the current process
|
||||
// PID, in case the re-exec lands with a new PID on platforms where
|
||||
// syscall.Exec behaviour differs.
|
||||
func postUpdateAdvantech(newVersion, pidFile string) error {
|
||||
// --- Write version file ---
|
||||
versionDir := filepath.Dir(advantechVersionFile)
|
||||
if err := os.MkdirAll(versionDir, 0755); err != nil {
|
||||
return fmt.Errorf("advantech: failed to create version directory %s: %w", versionDir, err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(advantechVersionFile, []byte(newVersion+"\n"), 0644); err != nil {
|
||||
return fmt.Errorf("advantech: failed to write version file %s: %w", advantechVersionFile, err)
|
||||
}
|
||||
logger.Debug("postUpdateAdvantech: wrote version %s to %s", newVersion, advantechVersionFile)
|
||||
|
||||
// --- Update PID file ---
|
||||
// syscall.Exec replaces the process image in-place so the PID is preserved.
|
||||
// We update the PID file here anyway so that any race between the old and
|
||||
// new binary is covered (e.g. on platforms that fork before exec).
|
||||
if pidFile != "" {
|
||||
pid := fmt.Sprintf("%d\n", os.Getpid())
|
||||
if err := os.WriteFile(pidFile, []byte(pid), 0644); err != nil {
|
||||
// Non-fatal: log and continue so the update still proceeds.
|
||||
logger.Debug("postUpdateAdvantech: warning: failed to update PID file %s: %v", pidFile, err)
|
||||
} else {
|
||||
logger.Debug("postUpdateAdvantech: updated PID file %s with PID %d", pidFile, os.Getpid())
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
//go:build windows
|
||||
|
||||
package updates
|
||||
|
||||
// postUpdateAdvantech is not supported on Windows.
|
||||
func postUpdateAdvantech(newVersion, pidFile string) error {
|
||||
return nil
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
//go:build !windows
|
||||
|
||||
package updates
|
||||
|
||||
import (
|
||||
"os"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
// reexec replaces the current process image with the binary at exePath,
|
||||
// forwarding all original arguments and environment variables.
|
||||
func reexec(exePath string) error {
|
||||
return syscall.Exec(exePath, os.Args, os.Environ())
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
//go:build windows
|
||||
|
||||
package updates
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
)
|
||||
|
||||
// reexec on Windows cannot use syscall.Exec (there is no exec syscall that
|
||||
// replaces the process image). Instead we start a new process and exit the
|
||||
// current one.
|
||||
func reexec(exePath string) error {
|
||||
cmd := exec.Command(exePath, os.Args[1:]...)
|
||||
cmd.Stdin = os.Stdin
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
cmd.Env = os.Environ()
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
return fmt.Errorf("failed to start updated binary: %w", err)
|
||||
}
|
||||
|
||||
// Exit the current process so the new binary takes over.
|
||||
os.Exit(0)
|
||||
return nil // unreachable
|
||||
}
|
||||
@@ -1,300 +0,0 @@
|
||||
package updates
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"crypto/tls"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/fosrl/newt/logger"
|
||||
)
|
||||
|
||||
// SelfUpdateConfig holds the configuration required to perform a self-update.
|
||||
type SelfUpdateConfig struct {
|
||||
// Endpoint is the base URL of the pangolin server (e.g. "https://app.pangolin.net")
|
||||
Endpoint string
|
||||
// NewtID is the newt client identifier used for authentication.
|
||||
NewtID string
|
||||
// Secret is the newt client secret used for authentication.
|
||||
Secret string
|
||||
// CurrentVersion is the version of the currently running binary.
|
||||
CurrentVersion string
|
||||
// Platform is the OS+arch string embedded at build time via ldflags
|
||||
// (e.g. "linux_amd64", "darwin_arm64"). When non-empty it is used
|
||||
// directly; when empty the value is derived from runtime.GOOS/GOARCH.
|
||||
Platform string
|
||||
// TLSConfig is an optional TLS configuration for the HTTP client (may be nil).
|
||||
TLSConfig *tls.Config
|
||||
}
|
||||
|
||||
// versionResponse mirrors the JSON returned by POST /api/v1/auth/newt/version
|
||||
type versionResponse struct {
|
||||
Data struct {
|
||||
LatestVersion string `json:"latestVersion"`
|
||||
CurrentIsLatest bool `json:"currentIsLatest"`
|
||||
DownloadUrl string `json:"downloadUrl"`
|
||||
Sha256 string `json:"sha256"`
|
||||
} `json:"data"`
|
||||
Success bool `json:"success"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// ErrAutoUpdateUnsupportedInOfficialContainer indicates auto-update is not
|
||||
// available when running inside official Fossorial container images.
|
||||
var ErrAutoUpdateUnsupportedInOfficialContainer = errors.New("auto-update unsupported in official Fossorial container images")
|
||||
|
||||
// isOfficialContainer returns true when the process is running inside an
|
||||
// official Fossorial-built container image. The image sets
|
||||
// NEWT_SYSTEM_SUBSTRATE="CONTAINER" at build time; users running newt in their own
|
||||
// containers (or bare-metal) will not have this variable set.
|
||||
func isOfficialContainer() bool {
|
||||
return os.Getenv("NEWT_SYSTEM_SUBSTRATE") == "CONTAINER"
|
||||
}
|
||||
|
||||
// platform returns the OS+arch string used in the newt release binary names,
|
||||
// e.g. "linux_amd64", "darwin_arm64", "windows_amd64".
|
||||
// It is used as a fallback when no platform was embedded at build time.
|
||||
func platform() string {
|
||||
goarch := runtime.GOARCH
|
||||
goos := runtime.GOOS
|
||||
|
||||
// Map Go arch names to the names used by newt releases
|
||||
archMap := map[string]string{
|
||||
"amd64": "amd64",
|
||||
"arm64": "arm64",
|
||||
"arm": "arm32",
|
||||
"riscv64": "riscv64",
|
||||
}
|
||||
|
||||
arch, ok := archMap[goarch]
|
||||
if !ok {
|
||||
arch = goarch
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%s_%s", goos, arch)
|
||||
}
|
||||
|
||||
// verifySHA256 checks that the file at path has the expected SHA-256 hex digest.
|
||||
func verifySHA256(path, expected string) error {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to open file for hashing: %w", err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
h := sha256.New()
|
||||
if _, err := io.Copy(h, f); err != nil {
|
||||
return fmt.Errorf("failed to hash file: %w", err)
|
||||
}
|
||||
|
||||
got := hex.EncodeToString(h.Sum(nil))
|
||||
if !strings.EqualFold(got, expected) {
|
||||
return fmt.Errorf("sha256 mismatch: expected %s, got %s", expected, got)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CheckAndSelfUpdate contacts the pangolin server, checks whether a newer
|
||||
// version of newt is available, downloads it if so, replaces the running
|
||||
// binary on disk, and re-executes from the new binary.
|
||||
//
|
||||
// It returns an error when the check or update fails. On a successful update
|
||||
// the function does not return – the process is replaced by the new binary via
|
||||
// syscall.Exec.
|
||||
func CheckAndSelfUpdate(cfg SelfUpdateConfig) error {
|
||||
logger.Debug("checkAndSelfUpdate: starting update check (currentVersion=%s)", cfg.CurrentVersion)
|
||||
|
||||
if isOfficialContainer() {
|
||||
logger.Debug("checkAndSelfUpdate: running inside official container, skipping auto-update")
|
||||
return fmt.Errorf("%w; pull a new image tag instead", ErrAutoUpdateUnsupportedInOfficialContainer)
|
||||
}
|
||||
|
||||
if cfg.CurrentVersion == "version_replaceme" {
|
||||
logger.Debug("checkAndSelfUpdate: development build detected, skipping auto-update")
|
||||
return fmt.Errorf("cannot auto-update a development build (version_replaceme)")
|
||||
}
|
||||
|
||||
baseEndpoint := strings.TrimRight(cfg.Endpoint, "/")
|
||||
|
||||
// Build the HTTP client.
|
||||
httpClient := &http.Client{Timeout: 30 * time.Second}
|
||||
if cfg.TLSConfig != nil {
|
||||
httpClient.Transport = &http.Transport{TLSClientConfig: cfg.TLSConfig}
|
||||
}
|
||||
|
||||
// Check the current binary path before we do anything else so we can fail
|
||||
// fast if the executable path cannot be determined.
|
||||
exePath, err := os.Executable()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to determine current executable path: %w", err)
|
||||
}
|
||||
exePath, err = filepath.EvalSymlinks(exePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to resolve symlinks for executable path: %w", err)
|
||||
}
|
||||
logger.Debug("checkAndSelfUpdate: current executable path: %s", exePath)
|
||||
|
||||
// --- Step 1: Ask the server for the latest version ---
|
||||
plat := cfg.Platform
|
||||
if plat == "" {
|
||||
plat = platform()
|
||||
}
|
||||
logger.Debug("checkAndSelfUpdate: querying server for latest version (platform=%s, endpoint=%s)", plat, baseEndpoint)
|
||||
reqBody, err := json.Marshal(map[string]string{
|
||||
"newtId": cfg.NewtID,
|
||||
"secret": cfg.Secret,
|
||||
"platform": plat,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal version request: %w", err)
|
||||
}
|
||||
|
||||
versionURL, err := url.JoinPath(baseEndpoint, "/api/v1/auth/newt/version")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to build version URL: %w", err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", versionURL, bytes.NewBuffer(reqBody))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create version request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("X-CSRF-Token", "x-csrf-protection")
|
||||
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to request version info: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusNoContent { // updates are disabled
|
||||
logger.Debug("checkAndSelfUpdate: server indicated updates are disabled (204 No Content)")
|
||||
return nil
|
||||
}
|
||||
|
||||
if resp.StatusCode == http.StatusNotFound { // older server without version endpoint
|
||||
logger.Debug("checkAndSelfUpdate: server does not support version endpoint (404 Not Found), skipping")
|
||||
return nil
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return fmt.Errorf("server returned status %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var verResp versionResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&verResp); err != nil {
|
||||
return fmt.Errorf("failed to parse version response: %w", err)
|
||||
}
|
||||
|
||||
if !verResp.Success {
|
||||
return fmt.Errorf("server error: %s", verResp.Message)
|
||||
}
|
||||
|
||||
if verResp.Data.CurrentIsLatest {
|
||||
logger.Debug("checkAndSelfUpdate: already up to date (%s)", cfg.CurrentVersion)
|
||||
return nil
|
||||
}
|
||||
|
||||
logger.Debug("checkAndSelfUpdate: update available %s → %s", cfg.CurrentVersion, verResp.Data.LatestVersion)
|
||||
|
||||
// --- Pre-download: verify we can write to the binary's directory ---
|
||||
// Do this before downloading so a permission failure doesn't waste bandwidth.
|
||||
exeDir := filepath.Dir(exePath)
|
||||
logger.Debug("checkAndSelfUpdate: verifying write access to %s", exeDir)
|
||||
writeTestFile, err := os.CreateTemp(exeDir, ".newt-write-test-*")
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot write to %s (you may need to run as root or with elevated permissions): %w", exeDir, err)
|
||||
}
|
||||
writeTestFile.Close()
|
||||
_ = os.Remove(writeTestFile.Name())
|
||||
|
||||
// --- Step 2: Download the new binary ---
|
||||
logger.Debug("checkAndSelfUpdate: beginning download of new binary")
|
||||
dlCtx, dlCancel := context.WithTimeout(context.Background(), 5*time.Minute)
|
||||
defer dlCancel()
|
||||
|
||||
dlReq, err := http.NewRequestWithContext(dlCtx, "GET", verResp.Data.DownloadUrl, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create download request: %w", err)
|
||||
}
|
||||
|
||||
dlResp, err := httpClient.Do(dlReq)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to download new binary: %w", err)
|
||||
}
|
||||
defer dlResp.Body.Close()
|
||||
|
||||
if dlResp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("download failed with status %d", dlResp.StatusCode)
|
||||
}
|
||||
|
||||
// Write to a temp file in the same directory as the current binary so that
|
||||
// an atomic rename works even across filesystem boundaries.
|
||||
tmpFile, err := os.CreateTemp(exeDir, ".newt-update-*")
|
||||
tmpPath := tmpFile.Name()
|
||||
|
||||
// Ensure the temp file is cleaned up on any error path.
|
||||
defer func() {
|
||||
_ = os.Remove(tmpPath)
|
||||
}()
|
||||
|
||||
if _, err := io.Copy(tmpFile, dlResp.Body); err != nil {
|
||||
_ = tmpFile.Close()
|
||||
return fmt.Errorf("failed to write downloaded binary: %w", err)
|
||||
}
|
||||
if err := tmpFile.Close(); err != nil {
|
||||
return fmt.Errorf("failed to close temp file: %w", err)
|
||||
}
|
||||
|
||||
// --- Verify SHA256 checksum if the server provided one ---
|
||||
if verResp.Data.Sha256 != "" {
|
||||
logger.Debug("checkAndSelfUpdate: verifying SHA256 checksum")
|
||||
if err := verifySHA256(tmpPath, verResp.Data.Sha256); err != nil {
|
||||
return fmt.Errorf("binary integrity check failed: %w", err)
|
||||
}
|
||||
logger.Debug("checkAndSelfUpdate: SHA256 checksum verified")
|
||||
} else {
|
||||
logger.Debug("checkAndSelfUpdate: no SHA256 checksum provided by server, skipping verification")
|
||||
}
|
||||
|
||||
// Make the new binary executable.
|
||||
if err := os.Chmod(tmpPath, 0755); err != nil {
|
||||
return fmt.Errorf("failed to set executable permission: %w", err)
|
||||
}
|
||||
|
||||
// --- Step 3: Replace the running binary ---
|
||||
// On Unix an atomic rename works even while the file is running.
|
||||
logger.Debug("checkAndSelfUpdate: replacing binary at %s", exePath)
|
||||
if err := os.Rename(tmpPath, exePath); err != nil {
|
||||
return fmt.Errorf("failed to replace binary (you may need to run as root): %w", err)
|
||||
}
|
||||
|
||||
systemSubstrate := os.Getenv("NEWT_SYSTEM_SUBSTRATE")
|
||||
logger.Debug("checkAndSelfUpdate: system substrate: %s", systemSubstrate)
|
||||
if systemSubstrate == "ADVANTECH_ROUTER_APP" {
|
||||
pidFile := os.Getenv("NEWT_PID_FILE")
|
||||
if err := postUpdateAdvantech(verResp.Data.LatestVersion, pidFile); err != nil {
|
||||
logger.Debug("checkAndSelfUpdate: advantech post-update steps failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Step 4: Re-exec ---
|
||||
logger.Debug("checkAndSelfUpdate: re-executing new binary")
|
||||
return reexec(exePath)
|
||||
}
|
||||
@@ -9,19 +9,11 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// VersionsAPIEntry represents one component's version payload.
|
||||
type VersionsAPIEntry struct {
|
||||
LatestVersion string `json:"latestVersion"`
|
||||
ReleaseNotes string `json:"releaseNotes"`
|
||||
}
|
||||
|
||||
// VersionsAPIResponse represents the versions API response.
|
||||
type VersionsAPIResponse struct {
|
||||
Data map[string]VersionsAPIEntry `json:"data"`
|
||||
Success bool `json:"success"`
|
||||
Error bool `json:"error"`
|
||||
Message string `json:"message"`
|
||||
Status int `json:"status"`
|
||||
// GitHubRelease represents the GitHub API response for a release
|
||||
type GitHubRelease struct {
|
||||
TagName string `json:"tag_name"`
|
||||
Name string `json:"name"`
|
||||
HTMLURL string `json:"html_url"`
|
||||
}
|
||||
|
||||
// Version represents a semantic version
|
||||
@@ -83,15 +75,14 @@ func (v Version) String() string {
|
||||
return fmt.Sprintf("%d.%d.%d", v.Major, v.Minor, v.Patch)
|
||||
}
|
||||
|
||||
// CheckForUpdate checks the Fossorial versions API for a newer version and prints an update banner if found.
|
||||
// CheckForUpdate checks GitHub for a newer version and prints an update banner if found
|
||||
func CheckForUpdate(owner, repo, currentVersion string) error {
|
||||
if currentVersion == "version_replaceme" {
|
||||
return nil
|
||||
}
|
||||
_ = owner
|
||||
|
||||
// Versions API URL
|
||||
url := "https://api.fossorial.io/api/v1/versions"
|
||||
// GitHub API URL for latest release
|
||||
url := fmt.Sprintf("https://api.github.com/repos/%s/%s/releases/latest", owner, repo)
|
||||
|
||||
// Create HTTP client with timeout
|
||||
client := &http.Client{
|
||||
@@ -106,18 +97,13 @@ func CheckForUpdate(owner, repo, currentVersion string) error {
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("versions API returned status: %d", resp.StatusCode)
|
||||
return fmt.Errorf("GitHub API returned status: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// Parse the JSON response.
|
||||
var versionsResp VersionsAPIResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&versionsResp); err != nil {
|
||||
return fmt.Errorf("failed to parse versions response: %w", err)
|
||||
}
|
||||
|
||||
componentVersion, ok := versionsResp.Data[repo]
|
||||
if !ok {
|
||||
return fmt.Errorf("component %q not found in versions API response", repo)
|
||||
// Parse the JSON response
|
||||
var release GitHubRelease
|
||||
if err := json.NewDecoder(resp.Body).Decode(&release); err != nil {
|
||||
return fmt.Errorf("failed to parse release info: %w", err)
|
||||
}
|
||||
|
||||
// Parse current and latest versions
|
||||
@@ -126,18 +112,14 @@ func CheckForUpdate(owner, repo, currentVersion string) error {
|
||||
return fmt.Errorf("invalid current version: %w", err)
|
||||
}
|
||||
|
||||
latestVer, err := parseVersion(componentVersion.LatestVersion)
|
||||
latestVer, err := parseVersion(release.TagName)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid latest version: %w", err)
|
||||
}
|
||||
|
||||
// Check if update is available
|
||||
if currentVer.isNewer(latestVer) {
|
||||
releaseNotes := componentVersion.ReleaseNotes
|
||||
if releaseNotes == "" {
|
||||
releaseNotes = "curl -fsSL https://static.pangolin.net/get-newt.sh | bash"
|
||||
}
|
||||
printUpdateBanner(currentVer.String(), latestVer.String(), releaseNotes)
|
||||
printUpdateBanner(currentVer.String(), latestVer.String(), "curl -fsSL https://static.pangolin.net/get-newt.sh | bash")
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
@@ -14,7 +14,6 @@ import (
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"software.sslmate.com/src/go-pkcs12"
|
||||
@@ -39,7 +38,6 @@ type Client struct {
|
||||
isConnected bool
|
||||
reconnectMux sync.RWMutex
|
||||
pingInterval time.Duration
|
||||
pongWait time.Duration // read deadline window; if no pong/message arrives within it, the connection is considered dead
|
||||
onConnect func() error
|
||||
onTokenUpdate func(token string)
|
||||
writeMux sync.Mutex
|
||||
@@ -52,11 +50,10 @@ type Client struct {
|
||||
serverVersion string
|
||||
configVersion int64 // Latest config version received from server
|
||||
configVersionMux sync.RWMutex
|
||||
processingMessage bool // Flag to track if a message is currently being processed
|
||||
processingMux sync.RWMutex // Protects processingMessage
|
||||
processingWg sync.WaitGroup // WaitGroup to wait for message processing to complete
|
||||
justProvisioned bool // Set to true when provisionIfNeeded exchanges a key for permanent credentials
|
||||
consecutiveFailures atomic.Int32 // Counts consecutive connection failures for log suppression
|
||||
processingMessage bool // Flag to track if a message is currently being processed
|
||||
processingMux sync.RWMutex // Protects processingMessage
|
||||
processingWg sync.WaitGroup // WaitGroup to wait for message processing to complete
|
||||
justProvisioned bool // Set to true when provisionIfNeeded exchanges a key for permanent credentials
|
||||
}
|
||||
|
||||
type ClientOption func(*Client)
|
||||
@@ -144,14 +141,6 @@ func NewClient(clientType string, ID, secret string, endpoint string, pingInterv
|
||||
Endpoint: endpoint,
|
||||
}
|
||||
|
||||
// Read deadline window: must exceed pingInterval so a healthy connection
|
||||
// (which gets a pong/message at least every pingInterval) is never torn
|
||||
// down, but a dead/half-open one is detected within ~2 ping cycles.
|
||||
pongWait := pingInterval * 2
|
||||
if pongWait < 20*time.Second {
|
||||
pongWait = 20 * time.Second
|
||||
}
|
||||
|
||||
client := &Client{
|
||||
config: config,
|
||||
baseURL: endpoint, // default value
|
||||
@@ -160,7 +149,6 @@ func NewClient(clientType string, ID, secret string, endpoint string, pingInterv
|
||||
reconnectInterval: 3 * time.Second,
|
||||
isConnected: false,
|
||||
pingInterval: pingInterval,
|
||||
pongWait: pongWait,
|
||||
clientType: clientType,
|
||||
}
|
||||
|
||||
@@ -184,11 +172,6 @@ func (c *Client) GetConfig() *Config {
|
||||
return c.config
|
||||
}
|
||||
|
||||
// GetConfigFilePath returns the resolved path to the config file used by this client.
|
||||
func (c *Client) GetConfigFilePath() string {
|
||||
return getConfigPath(c.clientType, c.configFilePath)
|
||||
}
|
||||
|
||||
func (c *Client) GetServerVersion() string {
|
||||
return c.serverVersion
|
||||
}
|
||||
@@ -421,7 +404,7 @@ func (c *Client) getToken() (string, error) {
|
||||
logger.Debug("Token response body: %s", string(body))
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
logger.Debug("Failed to get token with status code: %d", resp.StatusCode)
|
||||
logger.Error("Failed to get token with status code: %d", resp.StatusCode)
|
||||
telemetry.IncConnAttempt(ctx, "auth", "failure")
|
||||
etype := "io_error"
|
||||
if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden {
|
||||
@@ -504,12 +487,6 @@ func classifyWSDisconnect(err error) (result, reason string) {
|
||||
}
|
||||
}
|
||||
|
||||
// consecutiveFailureThreshold is the number of consecutive failures before
|
||||
// connection errors are promoted from Debug to Error. This suppresses the noisy
|
||||
// transient errors that occur during routine server updates while still surfacing
|
||||
// genuine connectivity problems.
|
||||
const consecutiveFailureThreshold = 3
|
||||
|
||||
func (c *Client) connectWithRetry() {
|
||||
for {
|
||||
select {
|
||||
@@ -518,16 +495,10 @@ func (c *Client) connectWithRetry() {
|
||||
default:
|
||||
err := c.establishConnection()
|
||||
if err != nil {
|
||||
n := c.consecutiveFailures.Add(1)
|
||||
if n >= consecutiveFailureThreshold {
|
||||
logger.Error("Failed to connect: %v. Retrying in %v...", err, c.reconnectInterval)
|
||||
} else {
|
||||
logger.Debug("Failed to connect (attempt %d): %v. Retrying in %v...", n, err, c.reconnectInterval)
|
||||
}
|
||||
logger.Error("Failed to connect: %v. Retrying in %v...", err, c.reconnectInterval)
|
||||
time.Sleep(c.reconnectInterval)
|
||||
continue
|
||||
}
|
||||
c.consecutiveFailures.Store(0)
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -631,28 +602,16 @@ func (c *Client) establishConnection() error {
|
||||
telemetry.SetWSConnectionState(true)
|
||||
c.setMetricsContext(ctx)
|
||||
sessionStart := time.Now()
|
||||
|
||||
// Per-connection lifecycle channel. The read pump closes it when this
|
||||
// connection ends so the matching ping monitor stops (avoids leaking a
|
||||
// ping-monitor goroutine on every reconnect).
|
||||
connClosed := make(chan struct{})
|
||||
|
||||
// Arm a read deadline and refresh it whenever a pong arrives. Combined with
|
||||
// the protocol-level pings sent by the ping monitor, this detects a dead or
|
||||
// half-open connection (e.g. a cloud load balancer keeping the socket open
|
||||
// after the backend API server died/restarted) instead of blocking on
|
||||
// ReadMessage forever — which previously required restarting newt by hand.
|
||||
_ = c.conn.SetReadDeadline(time.Now().Add(c.pongWait))
|
||||
// Wire up pong handler for metrics
|
||||
c.conn.SetPongHandler(func(appData string) error {
|
||||
_ = c.conn.SetReadDeadline(time.Now().Add(c.pongWait))
|
||||
telemetry.IncWSMessage(c.metricsContext(), "in", "pong")
|
||||
return nil
|
||||
})
|
||||
|
||||
// Start the ping monitor
|
||||
go c.pingMonitor(connClosed)
|
||||
go c.pingMonitor()
|
||||
// Start the read pump with disconnect detection
|
||||
go c.readPumpWithDisconnectDetection(sessionStart, connClosed)
|
||||
go c.readPumpWithDisconnectDetection(sessionStart)
|
||||
|
||||
if c.onConnect != nil {
|
||||
err := c.saveConfig()
|
||||
@@ -763,16 +722,11 @@ func (c *Client) sendPing() {
|
||||
err := c.conn.WriteJSON(pingMsg)
|
||||
if err == nil {
|
||||
telemetry.IncWSMessage(c.metricsContext(), "out", "ping")
|
||||
// Protocol-level ping: a standards-compliant server replies with a PONG,
|
||||
// which refreshes the read deadline. This is what lets us notice a
|
||||
// half-open connection where writes still succeed (buffered) but the
|
||||
// peer is gone.
|
||||
_ = c.conn.WriteControl(websocket.PingMessage, nil, time.Now().Add(10*time.Second))
|
||||
}
|
||||
c.writeMux.Unlock()
|
||||
|
||||
if err != nil {
|
||||
// Check if we're shutting down before logging error
|
||||
// Check if we're shutting down before logging error and reconnecting
|
||||
select {
|
||||
case <-c.done:
|
||||
// Expected during shutdown
|
||||
@@ -781,19 +735,13 @@ func (c *Client) sendPing() {
|
||||
logger.Error("Ping failed: %v", err)
|
||||
telemetry.IncWSKeepaliveFailure(c.metricsContext(), "ping_write")
|
||||
telemetry.IncWSReconnect(c.metricsContext(), "ping_write")
|
||||
// Close the connection and let the read pump trigger a single
|
||||
// reconnect (avoids racing two reconnects from here and the pump).
|
||||
c.writeMux.Lock()
|
||||
if c.conn != nil {
|
||||
_ = c.conn.Close()
|
||||
}
|
||||
c.writeMux.Unlock()
|
||||
c.reconnect()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) pingMonitor(connClosed <-chan struct{}) {
|
||||
func (c *Client) pingMonitor() {
|
||||
// Send an immediate ping as soon as we connect
|
||||
c.sendPing()
|
||||
|
||||
@@ -804,10 +752,6 @@ func (c *Client) pingMonitor(connClosed <-chan struct{}) {
|
||||
select {
|
||||
case <-c.done:
|
||||
return
|
||||
case <-connClosed:
|
||||
// This connection ended; stop pinging it. A new monitor is started
|
||||
// for the next connection by establishConnection.
|
||||
return
|
||||
case <-ticker.C:
|
||||
c.sendPing()
|
||||
}
|
||||
@@ -815,14 +759,12 @@ func (c *Client) pingMonitor(connClosed <-chan struct{}) {
|
||||
}
|
||||
|
||||
// readPumpWithDisconnectDetection reads messages and triggers reconnect on error
|
||||
func (c *Client) readPumpWithDisconnectDetection(started time.Time, connClosed chan struct{}) {
|
||||
func (c *Client) readPumpWithDisconnectDetection(started time.Time) {
|
||||
ctx := c.metricsContext()
|
||||
disconnectReason := "shutdown"
|
||||
disconnectResult := "success"
|
||||
|
||||
defer func() {
|
||||
// Signal the ping monitor for this connection to stop.
|
||||
close(connClosed)
|
||||
if c.conn != nil {
|
||||
c.conn.Close()
|
||||
}
|
||||
@@ -850,10 +792,6 @@ func (c *Client) readPumpWithDisconnectDetection(started time.Time, connClosed c
|
||||
default:
|
||||
msgType, p, err := c.conn.ReadMessage()
|
||||
if err == nil {
|
||||
// Any inbound traffic means the peer is alive — extend the
|
||||
// read deadline (also covers servers that answer the app-level
|
||||
// "newt/ping" with a message rather than a protocol pong).
|
||||
_ = c.conn.SetReadDeadline(time.Now().Add(c.pongWait))
|
||||
if msgType == websocket.BinaryMessage {
|
||||
telemetry.IncWSMessage(c.metricsContext(), "in", "binary")
|
||||
} else {
|
||||
@@ -993,19 +931,3 @@ func loadClientCertificate(p12Path string) (*tls.Config, error) {
|
||||
RootCAs: rootCAs,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// BuildTLSConfig creates a *tls.Config from the provided certificate files.
|
||||
// Pass empty strings / nil slice for fields that are not used.
|
||||
// Returns nil, nil when no TLS credentials are provided.
|
||||
func BuildTLSConfig(certFile, keyFile string, caFiles []string, pkcs12File string) (*tls.Config, error) {
|
||||
c := &Client{
|
||||
tlsConfig: TLSConfig{
|
||||
ClientCertFile: certFile,
|
||||
ClientKeyFile: keyFile,
|
||||
CAFiles: caFiles,
|
||||
PKCS12File: pkcs12File,
|
||||
},
|
||||
config: &Config{},
|
||||
}
|
||||
return c.setupTLS()
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
@@ -19,10 +20,6 @@ import (
|
||||
"github.com/fosrl/newt/logger"
|
||||
)
|
||||
|
||||
// getConfigPath returns the resolved config/credentials file path. Path
|
||||
// resolution (CLI flag / env var / OS default) is now owned by the calling
|
||||
// binary's root config loader; this is a fallback used only when a caller
|
||||
// (e.g. a test) doesn't supply an explicit path via WithConfigFile.
|
||||
func getConfigPath(clientType string, overridePath string) string {
|
||||
if overridePath != "" {
|
||||
return overridePath
|
||||
@@ -41,7 +38,7 @@ func getConfigPath(clientType string, overridePath string) string {
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(configDir, 0755); err != nil {
|
||||
logger.Debug("Failed to create config directory: %v", err)
|
||||
log.Printf("Failed to create config directory: %v", err)
|
||||
}
|
||||
|
||||
return filepath.Join(configDir, "config.json")
|
||||
@@ -50,14 +47,21 @@ func getConfigPath(clientType string, overridePath string) string {
|
||||
return configFile
|
||||
}
|
||||
|
||||
// loadConfig no longer merges credentials in from the file: the caller
|
||||
// resolves ID/Secret/Endpoint/TlsClientCert/ProvisioningKey/Name (from its
|
||||
// own settings file, env, and CLI flags) before constructing the Client. This
|
||||
// only figures out whether the file needs to be (re)written so that the
|
||||
// caller-provided values get cached to disk on first successful connect.
|
||||
func (c *Client) loadConfig() error {
|
||||
originalConfig := *c.config // Store original config to detect changes
|
||||
configPath := getConfigPath(c.clientType, c.configFilePath)
|
||||
|
||||
if c.config.ID != "" && c.config.Secret != "" && c.config.Endpoint != "" {
|
||||
logger.Debug("Config already provided, skipping loading from file")
|
||||
// Check if config file exists, if not, we should save it
|
||||
if _, err := os.Stat(configPath); os.IsNotExist(err) {
|
||||
logger.Info("Config file does not exist at %s, will create it", configPath)
|
||||
c.configNeedsSave = true
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
logger.Info("Loading config from: %s", configPath)
|
||||
data, err := os.ReadFile(configPath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
@@ -70,16 +74,57 @@ func (c *Client) loadConfig() error {
|
||||
if len(bytes.TrimSpace(data)) == 0 {
|
||||
logger.Info("Config file at %s is empty, will initialize it with provided values", configPath)
|
||||
c.configNeedsSave = true
|
||||
return nil
|
||||
}
|
||||
|
||||
var config Config
|
||||
if err := json.Unmarshal(data, &config); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Track what was loaded from file vs provided by CLI
|
||||
fileHadID := c.config.ID == ""
|
||||
fileHadSecret := c.config.Secret == ""
|
||||
fileHadCert := c.config.TlsClientCert == ""
|
||||
fileHadEndpoint := c.config.Endpoint == ""
|
||||
|
||||
if c.config.ID == "" {
|
||||
c.config.ID = config.ID
|
||||
}
|
||||
if c.config.Secret == "" {
|
||||
c.config.Secret = config.Secret
|
||||
}
|
||||
if c.config.TlsClientCert == "" {
|
||||
c.config.TlsClientCert = config.TlsClientCert
|
||||
}
|
||||
if c.config.Endpoint == "" {
|
||||
c.config.Endpoint = config.Endpoint
|
||||
c.baseURL = config.Endpoint
|
||||
}
|
||||
// Always load the provisioning key from the file if not already set
|
||||
if c.config.ProvisioningKey == "" {
|
||||
c.config.ProvisioningKey = config.ProvisioningKey
|
||||
}
|
||||
// Always load the name from the file if not already set
|
||||
if c.config.Name == "" {
|
||||
c.config.Name = config.Name
|
||||
}
|
||||
|
||||
// Check if CLI args provided values that override file values
|
||||
if (!fileHadID && originalConfig.ID != "") ||
|
||||
(!fileHadSecret && originalConfig.Secret != "") ||
|
||||
(!fileHadCert && originalConfig.TlsClientCert != "") ||
|
||||
(!fileHadEndpoint && originalConfig.Endpoint != "") {
|
||||
logger.Info("CLI arguments provided, config will be updated")
|
||||
c.configNeedsSave = true
|
||||
}
|
||||
|
||||
logger.Debug("Loaded config from %s", configPath)
|
||||
logger.Debug("Config: %+v", c.config)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// saveConfig persists the credential fields (id, secret, endpoint,
|
||||
// tlsClientCert, provisioningKey, name) into the config file. It's a
|
||||
// read-modify-write against the raw JSON so that any other settings a user
|
||||
// has placed in the same file (mtu, dns, etc.) are preserved rather than
|
||||
// clobbered.
|
||||
func (c *Client) saveConfig() error {
|
||||
if !c.configNeedsSave {
|
||||
logger.Debug("Config has not changed, skipping save")
|
||||
@@ -87,31 +132,7 @@ func (c *Client) saveConfig() error {
|
||||
}
|
||||
|
||||
configPath := getConfigPath(c.clientType, c.configFilePath)
|
||||
|
||||
existing := map[string]interface{}{}
|
||||
if data, err := os.ReadFile(configPath); err == nil && len(bytes.TrimSpace(data)) > 0 {
|
||||
if err := json.Unmarshal(data, &existing); err != nil {
|
||||
logger.Warn("Existing config file at %s is not valid JSON, other settings in it will be lost: %v", configPath, err)
|
||||
existing = map[string]interface{}{}
|
||||
}
|
||||
}
|
||||
|
||||
existing["id"] = c.config.ID
|
||||
existing["secret"] = c.config.Secret
|
||||
existing["endpoint"] = c.config.Endpoint
|
||||
|
||||
setOrDelete := func(key, value string) {
|
||||
if value != "" {
|
||||
existing[key] = value
|
||||
} else {
|
||||
delete(existing, key)
|
||||
}
|
||||
}
|
||||
setOrDelete("tlsClientCert", c.config.TlsClientCert)
|
||||
setOrDelete("provisioningKey", c.config.ProvisioningKey)
|
||||
setOrDelete("name", c.config.Name)
|
||||
|
||||
data, err := json.MarshalIndent(existing, "", " ")
|
||||
data, err := json.MarshalIndent(c.config, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -139,15 +160,6 @@ func interpolateString(s string) string {
|
||||
})
|
||||
}
|
||||
|
||||
// EnsureProvisioned exchanges a provisioning key for permanent credentials
|
||||
// synchronously, if one is configured and credentials aren't already present.
|
||||
// Callers that need the resolved ID/Secret before Connect() runs (which only
|
||||
// provisions lazily, in the background, on first connection attempt) should
|
||||
// call this first.
|
||||
func (c *Client) EnsureProvisioned() error {
|
||||
return c.provisionIfNeeded()
|
||||
}
|
||||
|
||||
// provisionIfNeeded checks whether a provisioning key is present and, if so,
|
||||
// exchanges it for a newt ID and secret by calling the registration endpoint.
|
||||
// On success the config is updated in-place and flagged for saving so that
|
||||
@@ -267,4 +279,4 @@ func (c *Client) provisionIfNeeded() error {
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
package websocket
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
@@ -34,63 +33,3 @@ func TestLoadConfig_EmptyFileMarksConfigForSave(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveConfig_PreservesUnrelatedSettings(t *testing.T) {
|
||||
t.Setenv("CONFIG_FILE", "")
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
configPath := filepath.Join(tmpDir, "config.json")
|
||||
initial := `{
|
||||
"mtu": 1300,
|
||||
"dns": "1.1.1.1",
|
||||
"provisioningKey": "spk-test"
|
||||
}`
|
||||
if err := os.WriteFile(configPath, []byte(initial), 0o644); err != nil {
|
||||
t.Fatalf("failed to create config file: %v", err)
|
||||
}
|
||||
|
||||
client := &Client{
|
||||
config: &Config{
|
||||
ID: "newt-id",
|
||||
Secret: "newt-secret",
|
||||
Endpoint: "https://example.com",
|
||||
// ProvisioningKey cleared, simulating a completed provisioning exchange.
|
||||
},
|
||||
clientType: "newt",
|
||||
configFilePath: configPath,
|
||||
configNeedsSave: true,
|
||||
}
|
||||
|
||||
if err := client.saveConfig(); err != nil {
|
||||
t.Fatalf("saveConfig returned error: %v", err)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(configPath)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read saved config: %v", err)
|
||||
}
|
||||
|
||||
var saved map[string]interface{}
|
||||
if err := json.Unmarshal(data, &saved); err != nil {
|
||||
t.Fatalf("saved config is not valid JSON: %v", err)
|
||||
}
|
||||
|
||||
if saved["mtu"] != float64(1300) {
|
||||
t.Errorf("expected mtu to be preserved, got %v", saved["mtu"])
|
||||
}
|
||||
if saved["dns"] != "1.1.1.1" {
|
||||
t.Errorf("expected dns to be preserved, got %v", saved["dns"])
|
||||
}
|
||||
if saved["id"] != "newt-id" {
|
||||
t.Errorf("expected id to be updated, got %v", saved["id"])
|
||||
}
|
||||
if saved["secret"] != "newt-secret" {
|
||||
t.Errorf("expected secret to be updated, got %v", saved["secret"])
|
||||
}
|
||||
if _, ok := saved["provisioningKey"]; ok {
|
||||
t.Errorf("expected provisioningKey to be cleared after provisioning, got %v", saved["provisioningKey"])
|
||||
}
|
||||
if client.configNeedsSave {
|
||||
t.Error("expected configNeedsSave to be reset after a successful save")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,6 @@ type Config struct {
|
||||
TlsClientCert string `json:"tlsClientCert"`
|
||||
ProvisioningKey string `json:"provisioningKey,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Blocked bool `json:"blocked,omitempty"`
|
||||
}
|
||||
|
||||
type TokenResponse struct {
|
||||
@@ -32,4 +31,4 @@ type WSMessage struct {
|
||||
Type string `json:"type"`
|
||||
Data interface{} `json:"data"`
|
||||
ConfigVersion int64 `json:"configVersion,omitempty"`
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user