mirror of
https://github.com/Control-D-Inc/ctrld.git
synced 2026-02-03 22:18:39 +00:00
Compare commits
788 Commits
v1.0.0
...
android-cr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4f4055147f | ||
|
|
b9ece6d7b9 | ||
|
|
c4efa1ab97 | ||
|
|
7cea5305e1 | ||
|
|
a20fbf95de | ||
|
|
628c4302aa | ||
|
|
8dc34f8bf5 | ||
|
|
b4faf82f76 | ||
|
|
a983dfaee2 | ||
|
|
62f73bcaa2 | ||
|
|
00e9d2bdd3 | ||
|
|
ace3b1e66e | ||
|
|
d1ea1ba08c | ||
|
|
c06c8aa859 | ||
|
|
0c2cc00c4f | ||
|
|
8d6ea91f35 | ||
|
|
7dfb77228f | ||
|
|
24910f1fa6 | ||
|
|
433a61d2ee | ||
|
|
3937e885f0 | ||
|
|
c651003cc4 | ||
|
|
b7ccfcb8b4 | ||
|
|
a9ed70200b | ||
|
|
c6365e6b74 | ||
|
|
dacc67e50f | ||
|
|
c60cf33af3 | ||
|
|
f27cbe3525 | ||
|
|
2de1b9929a | ||
|
|
8bf654aece | ||
|
|
84376ed719 | ||
|
|
7a136b8874 | ||
|
|
58c0e4f15a | ||
|
|
e0d35d8ba2 | ||
|
|
3b2e48761e | ||
|
|
b27064008e | ||
|
|
1ad63827e1 | ||
|
|
20e61550c2 | ||
|
|
020b814402 | ||
|
|
e578867118 | ||
|
|
46a1039f21 | ||
|
|
cc9e27de5f | ||
|
|
6ab3ab9faf | ||
|
|
e68bfa795a | ||
|
|
e60a92e93e | ||
|
|
62fe14f76b | ||
|
|
a0c5062e3a | ||
|
|
49eb152d02 | ||
|
|
b05056423a | ||
|
|
c7168739c7 | ||
|
|
5b1faf1ce3 | ||
|
|
513a6f9ec7 | ||
|
|
8db6fa4232 | ||
|
|
5036de2602 | ||
|
|
332f8ccc37 | ||
|
|
a582195cec | ||
|
|
9fe36ae984 | ||
|
|
54cb455522 | ||
|
|
8bd3b9e474 | ||
|
|
eff5ff580b | ||
|
|
c45f863ed8 | ||
|
|
414d4e356d | ||
|
|
ef697eb781 | ||
|
|
0631ffe831 | ||
|
|
7444d8517a | ||
|
|
3480043e40 | ||
|
|
619b6e7516 | ||
|
|
0123ca44fb | ||
|
|
7929aafe2a | ||
|
|
dc433f8dc9 | ||
|
|
8ccaeeab60 | ||
|
|
043a28eb33 | ||
|
|
c329402f5d | ||
|
|
23e6ad6e1f | ||
|
|
e6de78c1fa | ||
|
|
a670708f93 | ||
|
|
4ebe2fb5f4 | ||
|
|
3403b2039d | ||
|
|
e30ad31e0f | ||
|
|
81e0bad739 | ||
|
|
7d07d738dc | ||
|
|
0fae584e65 | ||
|
|
9e83085f2a | ||
|
|
41a00c68ac | ||
|
|
e3b99bf339 | ||
|
|
5007a87d3a | ||
|
|
60e65a37a6 | ||
|
|
d37d0e942c | ||
|
|
98042d8dbd | ||
|
|
af4b826b68 | ||
|
|
253a57ca01 | ||
|
|
caf98b4dfe | ||
|
|
398f71fd00 | ||
|
|
e1301ade96 | ||
|
|
7a23f82192 | ||
|
|
715bcc4aa1 | ||
|
|
0c74838740 | ||
|
|
4b05b6da7b | ||
|
|
375844ff1a | ||
|
|
1d207379cb | ||
|
|
fb49cb71e3 | ||
|
|
9618efbcde | ||
|
|
bb2210b06a | ||
|
|
917052723d | ||
|
|
fef85cadeb | ||
|
|
4a05fb6b28 | ||
|
|
6644ce53f2 | ||
|
|
72f0b89fdc | ||
|
|
41a97a6609 | ||
|
|
38064d6ad5 | ||
|
|
ae6945cedf | ||
|
|
3132d1b032 | ||
|
|
2716ae29bd | ||
|
|
1c50c2b6af | ||
|
|
cf6d16b439 | ||
|
|
60686f55ff | ||
|
|
47d7ace3a7 | ||
|
|
2d3779ec27 | ||
|
|
595071b608 | ||
|
|
57ef717080 | ||
|
|
eb27d1482b | ||
|
|
f57972ead7 | ||
|
|
168eaf538b | ||
|
|
1560455ca3 | ||
|
|
028475a193 | ||
|
|
f7a6dbe39b | ||
|
|
e573a490c9 | ||
|
|
ce3281e70d | ||
|
|
0fbfd160c9 | ||
|
|
20759017e6 | ||
|
|
69e0aab73e | ||
|
|
7ed6733fb7 | ||
|
|
9718ab8579 | ||
|
|
2687a4a018 | ||
|
|
2d9c60dea1 | ||
|
|
841be069b7 | ||
|
|
7833132917 | ||
|
|
e9e63b0983 | ||
|
|
4df470b869 | ||
|
|
89600f6091 | ||
|
|
f986a575e8 | ||
|
|
9c2fe8d21f | ||
|
|
8bcbb9249e | ||
|
|
a95d50c0af | ||
|
|
5db7d3577b | ||
|
|
c53a0ca1c4 | ||
|
|
6fd3d1788a | ||
|
|
087c1975e5 | ||
|
|
3713cbecc3 | ||
|
|
6046789fa4 | ||
|
|
3ea69b180c | ||
|
|
db6e977e3a | ||
|
|
a5c776c846 | ||
|
|
5a566c028a | ||
|
|
ff43c74d8d | ||
|
|
3c7255569c | ||
|
|
4a92ec4d2d | ||
|
|
9bbccb4082 | ||
|
|
4f62314646 | ||
|
|
cb49d0d947 | ||
|
|
89f7874fc6 | ||
|
|
221917e80b | ||
|
|
37d41bd215 | ||
|
|
8a96b8bec4 | ||
|
|
02ee113b95 | ||
|
|
f71dd78915 | ||
|
|
cd5619a05b | ||
|
|
a63a30c76b | ||
|
|
f5ba8be182 | ||
|
|
a9f76322bd | ||
|
|
ed39269c80 | ||
|
|
09426dcd36 | ||
|
|
17941882a9 | ||
|
|
70ab8032a0 | ||
|
|
8360bdc50a | ||
|
|
6837176ec7 | ||
|
|
5e9b4244e7 | ||
|
|
9b6a308958 | ||
|
|
71e327653a | ||
|
|
a56711796f | ||
|
|
09495f2a7c | ||
|
|
484643e114 | ||
|
|
da91aabc35 | ||
|
|
c654398981 | ||
|
|
47a90ec2a1 | ||
|
|
2875e22d0b | ||
|
|
c5d14e0075 | ||
|
|
84e06c363c | ||
|
|
5b9ccc5065 | ||
|
|
6ca1a7ccc7 | ||
|
|
9d666be5d4 | ||
|
|
65de7edcde | ||
|
|
0cdff0d368 | ||
|
|
f87220a908 | ||
|
|
30ea0c6499 | ||
|
|
9501e35c60 | ||
|
|
5ac9d17bdf | ||
|
|
cb14992ddc | ||
|
|
e88372fc8c | ||
|
|
b320662d67 | ||
|
|
ce353cd4d9 | ||
|
|
4befd33866 | ||
|
|
4b36e3ac44 | ||
|
|
f507bc8f9e | ||
|
|
14c88f4a6d | ||
|
|
3e388c2857 | ||
|
|
cfe1209d61 | ||
|
|
5a88a7c22c | ||
|
|
8c661c4401 | ||
|
|
e6f256d640 | ||
|
|
ede354166b | ||
|
|
282a8ce78e | ||
|
|
08fe04f1ee | ||
|
|
082d14a9ba | ||
|
|
617674ce43 | ||
|
|
7088df58dd | ||
|
|
9cbd9b3e44 | ||
|
|
e6586fd360 | ||
|
|
33a6db2599 | ||
|
|
70b0c4f7b9 | ||
|
|
5af3ec4f7b | ||
|
|
79476add12 | ||
|
|
1634a06330 | ||
|
|
a007394f60 | ||
|
|
62a0ba8731 | ||
|
|
e8d3ed1acd | ||
|
|
8b98faa441 | ||
|
|
30320ec9c7 | ||
|
|
5f4a399850 | ||
|
|
82e0d4b0c4 | ||
|
|
95a9df826d | ||
|
|
3b71d26cf3 | ||
|
|
c233ad9b1b | ||
|
|
12d6484b1c | ||
|
|
bc7b1cc6d8 | ||
|
|
ec684348ed | ||
|
|
18a19a3aa2 | ||
|
|
905f2d08c5 | ||
|
|
04947b4d87 | ||
|
|
72bf80533e | ||
|
|
9ddedf926e | ||
|
|
139dd62ff3 | ||
|
|
50ef00526e | ||
|
|
80cf79b9cb | ||
|
|
e6ad39b070 | ||
|
|
56f9c72569 | ||
|
|
dc48c908b8 | ||
|
|
9b0f0e792a | ||
|
|
b3eebb19b6 | ||
|
|
c24589a5be | ||
|
|
1e1c5a4dc8 | ||
|
|
339023421a | ||
|
|
a00d2a431a | ||
|
|
5aca118dbb | ||
|
|
411f7434f4 | ||
|
|
34801382f5 | ||
|
|
b9f2259ae4 | ||
|
|
19020a96bf | ||
|
|
96085147ff | ||
|
|
f3dd344026 | ||
|
|
486096416f | ||
|
|
5710f2e984 | ||
|
|
09936f1f07 | ||
|
|
0d6ca57536 | ||
|
|
3ddcb84db8 | ||
|
|
1012bf063f | ||
|
|
b8155e6182 | ||
|
|
9a34df61bb | ||
|
|
fbb879edf9 | ||
|
|
ac97c88876 | ||
|
|
a1fda2c0de | ||
|
|
f499770d45 | ||
|
|
4769da4ef4 | ||
|
|
c2556a8e39 | ||
|
|
29bf329f6a | ||
|
|
1dee4305bc | ||
|
|
429a98b690 | ||
|
|
da01a146d2 | ||
|
|
dd9f2465be | ||
|
|
b5cf0e2b31 | ||
|
|
1db159ad34 | ||
|
|
6604f973ac | ||
|
|
69ee6582e2 | ||
|
|
6f12667e8c | ||
|
|
b002dff624 | ||
|
|
affef963c1 | ||
|
|
56b2056190 | ||
|
|
c1e6f5126a | ||
|
|
1a8c1ec73d | ||
|
|
52954b8ceb | ||
|
|
a5025e35ea | ||
|
|
07f80c9ebf | ||
|
|
13db23553d | ||
|
|
3963fce43b | ||
|
|
ea4e5147bd | ||
|
|
7a491a4cc5 | ||
|
|
5ba90748f6 | ||
|
|
20f8f22bae | ||
|
|
b50cccac85 | ||
|
|
34ebe9b054 | ||
|
|
43d82cf1a7 | ||
|
|
ab88174091 | ||
|
|
ebcbf85373 | ||
|
|
87513cba6d | ||
|
|
64bcd2f00d | ||
|
|
cc6ae290f8 | ||
|
|
3e62bd3dbd | ||
|
|
8491f9c455 | ||
|
|
3ca754b438 | ||
|
|
8c7c3901e8 | ||
|
|
a9672dfff5 | ||
|
|
203a2ec8b8 | ||
|
|
810cbd1f4f | ||
|
|
49eebcdcbc | ||
|
|
e89021ec3a | ||
|
|
73a697b2fa | ||
|
|
9319d08046 | ||
|
|
7dc5138e91 | ||
|
|
8f189c919a | ||
|
|
906479a15c | ||
|
|
dabbf2037b | ||
|
|
b496147ce7 | ||
|
|
583718f234 | ||
|
|
fdb82f6ec3 | ||
|
|
5145729ab1 | ||
|
|
4d810261a4 | ||
|
|
18e8616834 | ||
|
|
d55563cac5 | ||
|
|
bb481d9bcc | ||
|
|
a163be3584 | ||
|
|
891b7cb2c6 | ||
|
|
176c22f229 | ||
|
|
faa0ed06b6 | ||
|
|
9515db7faf | ||
|
|
d822bf4257 | ||
|
|
0826671809 | ||
|
|
67d74774a9 | ||
|
|
5d65416227 | ||
|
|
49441f62f3 | ||
|
|
99651f6e5b | ||
|
|
edca1f4f89 | ||
|
|
3d834f00f6 | ||
|
|
6bb9e7a766 | ||
|
|
61fb71b1fa | ||
|
|
f8967c376f | ||
|
|
6d3c86c0be | ||
|
|
e42554f892 | ||
|
|
28984090e5 | ||
|
|
251255c746 | ||
|
|
32709dc64c | ||
|
|
71f26a6d81 | ||
|
|
44352f8006 | ||
|
|
af38623590 | ||
|
|
9c1665a759 | ||
|
|
eaad24e5e5 | ||
|
|
cfaf32f71a | ||
|
|
51b235b61a | ||
|
|
0a6d9d4454 | ||
|
|
dc700bbd52 | ||
|
|
cb445825f4 | ||
|
|
4d996e317b | ||
|
|
30c9012004 | ||
|
|
2a23feaf4b | ||
|
|
b82ad3720c | ||
|
|
8d2cb6091e | ||
|
|
3023f33dff | ||
|
|
22e97e981a | ||
|
|
44484e1231 | ||
|
|
eac60b87c7 | ||
|
|
8db28cb76e | ||
|
|
8dbe828b99 | ||
|
|
5c24acd952 | ||
|
|
998b9a5c5d | ||
|
|
0084e9ef26 | ||
|
|
122600bff2 | ||
|
|
41846b6d4c | ||
|
|
dfbcb1489d | ||
|
|
684019c2e3 | ||
|
|
e92619620d | ||
|
|
cebfd12d5c | ||
|
|
874ff01ab8 | ||
|
|
0bb8703f78 | ||
|
|
0bb51aa71d | ||
|
|
af2c1c87e0 | ||
|
|
8939debbc0 | ||
|
|
7591a0ccc6 | ||
|
|
c3ff8182af | ||
|
|
5897c174d3 | ||
|
|
f9a3f4c045 | ||
|
|
a2cb895cdc | ||
|
|
2bebe93e47 | ||
|
|
28ec1869fc | ||
|
|
17f6d7a77b | ||
|
|
9e6e647ff8 | ||
|
|
a2116e5eb5 | ||
|
|
564c9ef712 | ||
|
|
856abb71b7 | ||
|
|
0a30fdea69 | ||
|
|
4f125cf107 | ||
|
|
494d8be777 | ||
|
|
cd9c750884 | ||
|
|
91d319804b | ||
|
|
180eae60f2 | ||
|
|
d01f5c2777 | ||
|
|
294a90a807 | ||
|
|
c3b4ae9c79 | ||
|
|
09188bedf7 | ||
|
|
4614b98e94 | ||
|
|
990bc620f7 | ||
|
|
efb5a92571 | ||
|
|
8e0a96a44c | ||
|
|
43ff2f648c | ||
|
|
4816a09e3a | ||
|
|
3fea92c8b1 | ||
|
|
63f959c951 | ||
|
|
44ba6aadd9 | ||
|
|
d88cf52b4e | ||
|
|
58a00ea24a | ||
|
|
712b23a4bb | ||
|
|
baf836557c | ||
|
|
904b23eeac | ||
|
|
6aafe445f5 | ||
|
|
ebd516855b | ||
|
|
df4e04719e | ||
|
|
2440d922c6 | ||
|
|
f1b8d1c4ad | ||
|
|
79076bda35 | ||
|
|
9d2ea15346 | ||
|
|
77c1113ff7 | ||
|
|
e03ad4cd77 | ||
|
|
6e28517454 | ||
|
|
8ddbf881b3 | ||
|
|
c58516cfb0 | ||
|
|
34758f6205 | ||
|
|
a9959a6f3d | ||
|
|
511c4e696f | ||
|
|
bed7435b0c | ||
|
|
507c1afd59 | ||
|
|
2765487f10 | ||
|
|
80a88811cd | ||
|
|
823195c504 | ||
|
|
0f3e8c7ada | ||
|
|
ee5eb4fc4e | ||
|
|
d58d8074f4 | ||
|
|
94a0530991 | ||
|
|
073af0f89c | ||
|
|
6028b8f186 | ||
|
|
126477ef88 | ||
|
|
13391fd469 | ||
|
|
82e44b01af | ||
|
|
e355fd70ab | ||
|
|
d5c171735e | ||
|
|
b175368794 | ||
|
|
bcf4c25ba8 | ||
|
|
11b09af76d | ||
|
|
af0380a96a | ||
|
|
f39512b4c0 | ||
|
|
7ce62ccaec | ||
|
|
44c0a06996 | ||
|
|
f7d3db06c6 | ||
|
|
0ca37dc707 | ||
|
|
2bcba7b578 | ||
|
|
829e93c079 | ||
|
|
4896563e3c | ||
|
|
0c096d5f07 | ||
|
|
ab8f072388 | ||
|
|
32219e7d32 | ||
|
|
d292e03d1b | ||
|
|
5dd6336953 | ||
|
|
854a244ebb | ||
|
|
125b4b6077 | ||
|
|
46e8d4fad7 | ||
|
|
e5389ffecb | ||
|
|
46509be8a0 | ||
|
|
d3d2ed539f | ||
|
|
8496adc638 | ||
|
|
e1d078a2c3 | ||
|
|
0dee7518c4 | ||
|
|
774f07dd7f | ||
|
|
c271896551 | ||
|
|
82d887f52d | ||
|
|
6e27f877ff | ||
|
|
39a2cab051 | ||
|
|
72d2f4e7e3 | ||
|
|
19bc44a7f3 | ||
|
|
59dc74ffbb | ||
|
|
12c8ab696f | ||
|
|
28f32bd7e5 | ||
|
|
6b43639be5 | ||
|
|
6be80e4827 | ||
|
|
437fb1b16d | ||
|
|
61b6431b6e | ||
|
|
7ccecdd9f7 | ||
|
|
e43b2b5530 | ||
|
|
2cd8b7e021 | ||
|
|
d6768c4c39 | ||
|
|
59a895bfe2 | ||
|
|
cacd957594 | ||
|
|
2cd063ebd6 | ||
|
|
9ed8e49a08 | ||
|
|
66cb7cc21d | ||
|
|
4bf09120ff | ||
|
|
be0769e433 | ||
|
|
7b476e38be | ||
|
|
0a7d3445f4 | ||
|
|
76d2e2c226 | ||
|
|
3007cb86ec | ||
|
|
fa3af372ab | ||
|
|
48a780fc3e | ||
|
|
28df551195 | ||
|
|
e65a71b2ae | ||
|
|
dc61fd2554 | ||
|
|
a4edf266f0 | ||
|
|
7af59ee589 | ||
|
|
3f3c1d6d78 | ||
|
|
ab1d7fd796 | ||
|
|
6c2996a921 | ||
|
|
de32dd8ba4 | ||
|
|
d43e50ee2d | ||
|
|
aec2596262 | ||
|
|
78a7c87ecc | ||
|
|
1d3f8757bc | ||
|
|
c0c69d0739 | ||
|
|
1aa991298a | ||
|
|
f3a3227f21 | ||
|
|
a4c1983657 | ||
|
|
cc28b92935 | ||
|
|
eaa907a647 | ||
|
|
de951fd895 | ||
|
|
3f211d3cc2 | ||
|
|
2f46d512c6 | ||
|
|
12148ec231 | ||
|
|
9fe6af684f | ||
|
|
472bb05e95 | ||
|
|
50bfed706d | ||
|
|
350d8355b1 | ||
|
|
03781d4cec | ||
|
|
67e4afc06e | ||
|
|
32482809b7 | ||
|
|
c315d21be9 | ||
|
|
48b2031269 | ||
|
|
41139b3343 | ||
|
|
d5e6c7b13f | ||
|
|
60d6734e1f | ||
|
|
e684c7d8c4 | ||
|
|
ce35383341 | ||
|
|
5553490b27 | ||
|
|
eaf39f48a0 | ||
|
|
a5ddbdcb42 | ||
|
|
0c99d27be5 | ||
|
|
b9eb89c02e | ||
|
|
53f8d006f0 | ||
|
|
929de49c7b | ||
|
|
542c4f7daf | ||
|
|
c941f9c621 | ||
|
|
25eae187db | ||
|
|
726a25a7ea | ||
|
|
a46bb152af | ||
|
|
bbfa7c6c22 | ||
|
|
1cd54a48e9 | ||
|
|
2d950eecdf | ||
|
|
b143e46eb0 | ||
|
|
8fda856e24 | ||
|
|
54e63ccf9b | ||
|
|
ee53db1e35 | ||
|
|
fc502b920b | ||
|
|
20eae82f11 | ||
|
|
d2fc530316 | ||
|
|
7ac5555a84 | ||
|
|
15d397d8a6 | ||
|
|
b471adfb09 | ||
|
|
d7a38363e6 | ||
|
|
90def8f9b5 | ||
|
|
b126db453b | ||
|
|
601d357456 | ||
|
|
3a2024ebd7 | ||
|
|
6cd451acec | ||
|
|
3b6c12abd4 | ||
|
|
d9dfc584e7 | ||
|
|
57fa68970a | ||
|
|
fa14f1dadf | ||
|
|
9689607409 | ||
|
|
d75f871541 | ||
|
|
45895067c6 | ||
|
|
521f06dcc1 | ||
|
|
5b6a3a4c6f | ||
|
|
be497a68de | ||
|
|
c872a3b3f6 | ||
|
|
e0ae0f8e7b | ||
|
|
ad4ca32873 | ||
|
|
24100c4cbe | ||
|
|
e3a792d50d | ||
|
|
440d085c6d | ||
|
|
270ea9f6ca | ||
|
|
7a156d7d15 | ||
|
|
4c45e6cf3d | ||
|
|
704bc27dba | ||
|
|
b267572b38 | ||
|
|
5cad0d6be1 | ||
|
|
56d8dc865f | ||
|
|
d57c1d6d44 | ||
|
|
02fa7fbe2e | ||
|
|
07689954bf | ||
|
|
a7ea20b117 | ||
|
|
43fecdf60f | ||
|
|
31239684c7 | ||
|
|
5528ac8bf1 | ||
|
|
411e23ecfe | ||
|
|
7bf231643b | ||
|
|
2326160f2f | ||
|
|
68fe7e8406 | ||
|
|
c7bad63869 | ||
|
|
69319c6b41 | ||
|
|
9df381d3d1 | ||
|
|
0af7f64bca | ||
|
|
f73cbde7a5 | ||
|
|
0645a738ad | ||
|
|
d52cd11322 | ||
|
|
d3d08022cc | ||
|
|
21c8b9f8e7 | ||
|
|
6c55d8f139 | ||
|
|
ccdb2a3f70 | ||
|
|
f5ef9b917e | ||
|
|
a5443d5ca4 | ||
|
|
2c7d95bba2 | ||
|
|
8a2cdbfaa3 | ||
|
|
c94be0df35 | ||
|
|
4b6a976747 | ||
|
|
0043fdf859 | ||
|
|
24e62e18fa | ||
|
|
663dbbb476 | ||
|
|
471427a439 | ||
|
|
a777c4b00f | ||
|
|
dcc4cdd316 | ||
|
|
9c22701940 | ||
|
|
a77a924320 | ||
|
|
95dbf71939 | ||
|
|
8869e33a20 | ||
|
|
c94e1b02d2 | ||
|
|
42d29b626b | ||
|
|
b65a5ac283 | ||
|
|
ba48ff5965 | ||
|
|
b3a342bc44 | ||
|
|
9927803497 | ||
|
|
f0c604a9f1 | ||
|
|
8a56389396 | ||
|
|
9f7bfc76db | ||
|
|
a7a5501ea5 | ||
|
|
c401c4ef87 | ||
|
|
8ffb42962a | ||
|
|
aad04200cb | ||
|
|
4bfcacaf3c | ||
|
|
5b362412be | ||
|
|
ccf07a7d1c | ||
|
|
e4eb3b2ded | ||
|
|
77b62f8734 | ||
|
|
096e7ea429 | ||
|
|
3e6f6cc721 | ||
|
|
7dab688252 | ||
|
|
7cd1f7adda | ||
|
|
9a249c3029 | ||
|
|
0dfa377e08 | ||
|
|
14bc29751f | ||
|
|
e6800fbc82 | ||
|
|
4f6c2032a1 | ||
|
|
d1589bd9d6 | ||
|
|
85c95a6a3a | ||
|
|
fa50cd4df4 | ||
|
|
018f6651c1 | ||
|
|
1a40767cb7 | ||
|
|
12512a60da | ||
|
|
b0114dfaeb | ||
|
|
fb20d443c1 | ||
|
|
262dcb1dff | ||
|
|
8b08cc8a6e | ||
|
|
930a5ad439 | ||
|
|
8852f60ccb | ||
|
|
2e1b3f9d07 | ||
|
|
6d3c82d38d | ||
|
|
cad71997aa | ||
|
|
82900eeca6 | ||
|
|
84fca06c62 | ||
|
|
64f2dcb25b | ||
|
|
4c2d21a8f8 | ||
|
|
4172fc09d0 | ||
|
|
d9b699501d | ||
|
|
71b1b324db | ||
|
|
35c890048b | ||
|
|
bac6810956 | ||
|
|
997ec342e0 | ||
|
|
e385547461 | ||
|
|
83b551fb2d | ||
|
|
45f827a2c5 | ||
|
|
3218b5fac1 | ||
|
|
df514d15a5 | ||
|
|
50b0e5a4b0 | ||
|
|
6428ac23a0 | ||
|
|
790cb773e2 | ||
|
|
9dab097268 | ||
|
|
f13f61592c | ||
|
|
2f42fc055d | ||
|
|
a08b39be16 | ||
|
|
d73ece9d9e | ||
|
|
be6e2cc0a2 | ||
|
|
56d8c102e1 | ||
|
|
3602484109 | ||
|
|
0e09b45bca | ||
|
|
8571580aae | ||
|
|
d3fe2c730c | ||
|
|
318fec27de | ||
|
|
beca95d5b9 | ||
|
|
59619476ca | ||
|
|
31b30c52b1 | ||
|
|
851f9b9742 | ||
|
|
b8772d7b4a | ||
|
|
eb0dd6235e | ||
|
|
1c2cd555bd | ||
|
|
8c47ffb5ec | ||
|
|
44bd580e48 | ||
|
|
61156453b2 | ||
|
|
37de5441c1 | ||
|
|
149941f17f | ||
|
|
4ea1e64795 | ||
|
|
06372031b5 | ||
|
|
c82a0e2562 | ||
|
|
b0dc96aa01 | ||
|
|
31e4bcb4c3 | ||
|
|
9fc546443b | ||
|
|
8a2c48e996 | ||
|
|
1186963531 | ||
|
|
837563dcd5 | ||
|
|
cd37d93b06 | ||
|
|
340016ab70 | ||
|
|
4c8ea45922 | ||
|
|
056b76d5a8 | ||
|
|
f9d6223af5 | ||
|
|
f6371360bc | ||
|
|
46965b04b4 | ||
|
|
065a391ff4 | ||
|
|
d830706692 | ||
|
|
14ddb1faa0 | ||
|
|
326d7a43d4 | ||
|
|
87091f20b0 | ||
|
|
d418e57def | ||
|
|
49e9b8b51c | ||
|
|
dc7d77b22e | ||
|
|
a9fabd1b79 | ||
|
|
47c280cf1d | ||
|
|
05cfb9b661 | ||
|
|
99b0cbedc3 | ||
|
|
1f2bd90308 | ||
|
|
a318e19e33 | ||
|
|
b00a7c34ee | ||
|
|
d5344aea52 | ||
|
|
8e164185b9 | ||
|
|
53306235dc | ||
|
|
3a5c71514c | ||
|
|
279e938b2a | ||
|
|
9f90811567 | ||
|
|
6edd42629e | ||
|
|
8e91123dbf | ||
|
|
3014556f2d | ||
|
|
b021833ed6 | ||
|
|
7b13fd862d | ||
|
|
114ef9aad6 | ||
|
|
ec72af1916 | ||
|
|
9e7578fb29 | ||
|
|
e331a4113a | ||
|
|
e6d77e2586 | ||
|
|
b93970ccfd | ||
|
|
30fefe7ab9 | ||
|
|
fa3c3e8a29 | ||
|
|
a6b3c4a757 | ||
|
|
b03aa39b83 | ||
|
|
837d3195ca | ||
|
|
a7ae6c9853 | ||
|
|
ebcc545547 | ||
|
|
1cdce73070 | ||
|
|
5f9ac5889b | ||
|
|
924304a13d | ||
|
|
0240f7ab15 | ||
|
|
64dff35143 | ||
|
|
d2c47ba523 | ||
|
|
ccada70e31 | ||
|
|
fe0faac8c4 | ||
|
|
bb51a40166 | ||
|
|
d42ee31a7c | ||
|
|
0556825a11 | ||
|
|
b2a6f18a1c |
2
.dockerignore
Normal file
2
.dockerignore
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
Dockerfile
|
||||||
|
.git/
|
||||||
10
.github/workflows/ci.yml
vendored
10
.github/workflows/ci.yml
vendored
@@ -9,18 +9,18 @@ jobs:
|
|||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
os: ["windows-latest", "ubuntu-latest", "macOS-latest"]
|
os: ["windows-latest", "ubuntu-latest", "macOS-latest"]
|
||||||
go: ["1.19.x"]
|
go: ["1.23.x"]
|
||||||
runs-on: ${{ matrix.os }}
|
runs-on: ${{ matrix.os }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v1
|
- uses: actions/checkout@v3
|
||||||
with:
|
with:
|
||||||
fetch-depth: 1
|
fetch-depth: 1
|
||||||
- uses: WillAbides/setup-go-faster@v1.7.0
|
- uses: WillAbides/setup-go-faster@v1.8.0
|
||||||
with:
|
with:
|
||||||
go-version: ${{ matrix.go }}
|
go-version: ${{ matrix.go }}
|
||||||
- run: "go test -race ./..."
|
- run: "go test -race ./..."
|
||||||
- uses: dominikh/staticcheck-action@v1.2.0
|
- uses: dominikh/staticcheck-action@v1.3.1
|
||||||
with:
|
with:
|
||||||
version: "2022.1.1"
|
version: "2024.1.1"
|
||||||
install-go: false
|
install-go: false
|
||||||
cache-key: ${{ matrix.go }}
|
cache-key: ${{ matrix.go }}
|
||||||
|
|||||||
14
.gitignore
vendored
14
.gitignore
vendored
@@ -1,2 +1,14 @@
|
|||||||
|
|
||||||
dist/
|
dist/
|
||||||
|
gon.hcl
|
||||||
|
|
||||||
|
/Build
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# Release folder
|
||||||
|
dist/
|
||||||
|
|
||||||
|
# Binaries
|
||||||
|
ctrld-*
|
||||||
|
|
||||||
|
# generated file
|
||||||
|
cmd/cli/rsrc_*.syso
|
||||||
|
|||||||
35
.goreleaser-darwin.yaml
Normal file
35
.goreleaser-darwin.yaml
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
before:
|
||||||
|
hooks:
|
||||||
|
- go mod tidy
|
||||||
|
builds:
|
||||||
|
- id: ctrld-darwin
|
||||||
|
env:
|
||||||
|
- CGO_ENABLED=0
|
||||||
|
flags:
|
||||||
|
- -trimpath
|
||||||
|
ldflags:
|
||||||
|
- -s -w
|
||||||
|
- -X main.version={{.Version}}
|
||||||
|
- -X main.commit={{.Commit}}
|
||||||
|
goos:
|
||||||
|
- darwin
|
||||||
|
goarch:
|
||||||
|
- amd64
|
||||||
|
- arm64
|
||||||
|
main: ./cmd/ctrld
|
||||||
|
hooks:
|
||||||
|
post: gon gon.hcl
|
||||||
|
archives:
|
||||||
|
- strip_parent_binary_folder: true
|
||||||
|
wrap_in_directory: true
|
||||||
|
files:
|
||||||
|
- README.md
|
||||||
|
checksum:
|
||||||
|
name_template: 'checksums.txt'
|
||||||
|
snapshot:
|
||||||
|
name_template: "{{ incpatch .Version }}-next"
|
||||||
|
changelog:
|
||||||
|
sort: asc
|
||||||
|
filters:
|
||||||
|
exclude:
|
||||||
|
- '^docs:'
|
||||||
44
.goreleaser-qf.yaml
Normal file
44
.goreleaser-qf.yaml
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
before:
|
||||||
|
hooks:
|
||||||
|
- go mod tidy
|
||||||
|
builds:
|
||||||
|
- id: ctrld
|
||||||
|
env:
|
||||||
|
- CGO_ENABLED=0
|
||||||
|
flags:
|
||||||
|
- -trimpath
|
||||||
|
ldflags:
|
||||||
|
- -s -w
|
||||||
|
- -X main.version={{.Version}}
|
||||||
|
- -X main.commit={{.Commit}}
|
||||||
|
goos:
|
||||||
|
- darwin
|
||||||
|
- linux
|
||||||
|
- windows
|
||||||
|
goarch:
|
||||||
|
- 386
|
||||||
|
- arm
|
||||||
|
- amd64
|
||||||
|
- arm64
|
||||||
|
tags:
|
||||||
|
- qf
|
||||||
|
main: ./cmd/ctrld
|
||||||
|
hooks:
|
||||||
|
post: /bin/sh ./scripts/upx.sh {{ .Path }}
|
||||||
|
archives:
|
||||||
|
- format_overrides:
|
||||||
|
- goos: windows
|
||||||
|
format: zip
|
||||||
|
strip_parent_binary_folder: true
|
||||||
|
wrap_in_directory: true
|
||||||
|
files:
|
||||||
|
- README.md
|
||||||
|
checksum:
|
||||||
|
name_template: 'checksums.txt'
|
||||||
|
snapshot:
|
||||||
|
name_template: "{{ incpatch .Version }}-next"
|
||||||
|
changelog:
|
||||||
|
sort: asc
|
||||||
|
filters:
|
||||||
|
exclude:
|
||||||
|
- '^docs:'
|
||||||
@@ -2,20 +2,24 @@ before:
|
|||||||
hooks:
|
hooks:
|
||||||
- go mod tidy
|
- go mod tidy
|
||||||
builds:
|
builds:
|
||||||
- env:
|
- id: ctrld
|
||||||
|
env:
|
||||||
- CGO_ENABLED=0
|
- CGO_ENABLED=0
|
||||||
flags:
|
flags:
|
||||||
- -trimpath
|
- -trimpath
|
||||||
ldflags:
|
ldflags:
|
||||||
- -s -w
|
- -s -w
|
||||||
|
- -X main.version={{.Version}}
|
||||||
|
- -X main.commit={{.Commit}}
|
||||||
goos:
|
goos:
|
||||||
- linux
|
- linux
|
||||||
|
- freebsd
|
||||||
- windows
|
- windows
|
||||||
- darwin
|
|
||||||
goarch:
|
goarch:
|
||||||
- 386
|
- 386
|
||||||
- arm
|
- arm
|
||||||
- mips
|
- mips
|
||||||
|
- mipsle
|
||||||
- amd64
|
- amd64
|
||||||
- arm64
|
- arm64
|
||||||
goarm:
|
goarm:
|
||||||
@@ -25,6 +29,12 @@ builds:
|
|||||||
gomips:
|
gomips:
|
||||||
- softfloat
|
- softfloat
|
||||||
main: ./cmd/ctrld
|
main: ./cmd/ctrld
|
||||||
|
hooks:
|
||||||
|
post: /bin/sh ./scripts/upx.sh {{ .Path }}
|
||||||
|
ignore:
|
||||||
|
- goos: freebsd
|
||||||
|
goarch: arm
|
||||||
|
goarm: 5
|
||||||
archives:
|
archives:
|
||||||
- format_overrides:
|
- format_overrides:
|
||||||
- goos: windows
|
- goos: windows
|
||||||
|
|||||||
21
LICENSE
Normal file
21
LICENSE
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2022 Control D Inc
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
244
README.md
244
README.md
@@ -1,9 +1,22 @@
|
|||||||
# ctrld
|
# ctrld
|
||||||
|
|
||||||
|

|
||||||
|
[](https://pkg.go.dev/github.com/Control-D-Inc/ctrld)
|
||||||
|
[](https://goreportcard.com/report/github.com/Control-D-Inc/ctrld)
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
A highly configurable DNS forwarding proxy with support for:
|
A highly configurable DNS forwarding proxy with support for:
|
||||||
- Multiple listeners for incoming queries
|
- Multiple listeners for incoming queries
|
||||||
- Multiple upstreams with fallbacks
|
- Multiple upstreams with fallbacks
|
||||||
- Multiple network policy driven DNS query steering
|
- Multiple network policy driven DNS query steering (via network cidr, MAC address or FQDN)
|
||||||
- Policy driven domain based "split horizon" DNS with wildcard support
|
- Policy driven domain based "split horizon" DNS with wildcard support
|
||||||
|
- Integrations with common router vendors and firmware
|
||||||
|
- LAN client discovery via DHCP, mDNS, ARP, NDP, hosts file parsing
|
||||||
|
- Prometheus metrics exporter
|
||||||
|
|
||||||
|
## TLDR
|
||||||
|
Proxy legacy DNS traffic to secure DNS upstreams in highly configurable ways.
|
||||||
|
|
||||||
All DNS protocols are supported, including:
|
All DNS protocols are supported, including:
|
||||||
- `UDP 53`
|
- `UDP 53`
|
||||||
@@ -12,83 +25,211 @@ All DNS protocols are supported, including:
|
|||||||
- `DNS-over-HTTP/3` (DOH3)
|
- `DNS-over-HTTP/3` (DOH3)
|
||||||
- `DNS-over-QUIC`
|
- `DNS-over-QUIC`
|
||||||
|
|
||||||
## Use Cases
|
# Use Cases
|
||||||
1. Use secure DNS protocols on networks and devices that don't natively support them (legacy routers, legacy OSes, TVs, smart toasters).
|
1. Use secure DNS protocols on networks and devices that don't natively support them (legacy routers, legacy OSes, TVs, smart toasters).
|
||||||
2. Create source IP based DNS routing policies with variable secure DNS upstreams. Subnet 1 (admin) uses upstream resolver A, while Subnet 2 (employee) uses upstream resolver B.
|
2. Create source IP based DNS routing policies with variable secure DNS upstreams. Subnet 1 (admin) uses upstream resolver A, while Subnet 2 (employee) uses upstream resolver B.
|
||||||
3. Create destination IP based DNS routing policies with variable secure DNS upstreams. Listener 1 uses upstream resolver C, while Listener 2 uses upstream resolver D.
|
3. Create destination IP based DNS routing policies with variable secure DNS upstreams. Listener 1 uses upstream resolver C, while Listener 2 uses upstream resolver D.
|
||||||
4. Create domain level "split horizon" DNS routing policies to send internal domains (*.company.int) to a local DNS server, while everything else goes to another upstream.
|
4. Create domain level "split horizon" DNS routing policies to send internal domains (*.company.int) to a local DNS server, while everything else goes to another upstream.
|
||||||
|
5. Deploy on a router and create LAN client specific DNS routing policies from a web GUI (When using ControlD.com).
|
||||||
|
|
||||||
|
|
||||||
## OS Support
|
## OS Support
|
||||||
- Windows (386, amd64, arm)
|
- Windows (386, amd64, arm)
|
||||||
- Mac (amd64, arm)
|
- Windows Server (386, amd64)
|
||||||
|
- MacOS (amd64, arm64)
|
||||||
- Linux (386, amd64, arm, mips)
|
- Linux (386, amd64, arm, mips)
|
||||||
|
- FreeBSD (386, amd64, arm)
|
||||||
|
- Common routers (See below)
|
||||||
|
|
||||||
## Download
|
|
||||||
Download pre-compiled binaries from the [Releases](#) section.
|
|
||||||
|
|
||||||
## Build
|
### Supported Routers
|
||||||
`ctrld` requires `go1.19+`:
|
You can run `ctrld` on any supported router. The list of supported routers and firmware includes:
|
||||||
|
- Asus Merlin
|
||||||
|
- DD-WRT
|
||||||
|
- Firewalla
|
||||||
|
- FreshTomato
|
||||||
|
- GL.iNet
|
||||||
|
- OpenWRT
|
||||||
|
- pfSense / OPNsense
|
||||||
|
- Synology
|
||||||
|
- Ubiquiti (UniFi, EdgeOS)
|
||||||
|
|
||||||
|
`ctrld` will attempt to interface with dnsmasq (or Windows Server) whenever possible and set itself as the upstream, while running on port 5354. On FreeBSD based OSes, `ctrld` will terminate dnsmasq and unbound in order to be able to listen on port 53 directly.
|
||||||
|
|
||||||
|
# Install
|
||||||
|
There are several ways to download and install `ctrld`.
|
||||||
|
|
||||||
|
## Quick Install
|
||||||
|
The simplest way to download and install `ctrld` is to use the following installer command on any UNIX-like platform:
|
||||||
|
|
||||||
```shell
|
```shell
|
||||||
$ go build
|
sh -c 'sh -c "$(curl -sL https://api.controld.com/dl)"'
|
||||||
|
```
|
||||||
|
|
||||||
|
Windows user and prefer Powershell (who doesn't)? No problem, execute this command instead in administrative PowerShell:
|
||||||
|
```shell
|
||||||
|
(Invoke-WebRequest -Uri 'https://api.controld.com/dl/ps1' -UseBasicParsing).Content | Set-Content "$env:TEMPctrld_install.ps1"; Invoke-Expression "& '$env:TEMPctrld_install.ps1'"
|
||||||
|
```
|
||||||
|
|
||||||
|
Or you can pull and run a Docker container from [Docker Hub](https://hub.docker.com/r/controldns/ctrld)
|
||||||
|
```shell
|
||||||
|
docker run -d --name=ctrld -p 127.0.0.1:53:53/tcp -p 127.0.0.1:53:53/udp controldns/ctrld:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
## Download Manually
|
||||||
|
Alternatively, if you know what you're doing you can download pre-compiled binaries from the [Releases](https://github.com/Control-D-Inc/ctrld/releases) section for the appropriate platform.
|
||||||
|
|
||||||
|
## Build
|
||||||
|
Lastly, you can build `ctrld` from source which requires `go1.21+`:
|
||||||
|
|
||||||
|
```shell
|
||||||
|
go build ./cmd/ctrld
|
||||||
|
```
|
||||||
|
|
||||||
|
or
|
||||||
|
|
||||||
|
```shell
|
||||||
|
go install github.com/Control-D-Inc/ctrld/cmd/ctrld@latest
|
||||||
```
|
```
|
||||||
|
|
||||||
or
|
or
|
||||||
|
|
||||||
```shell
|
```shell
|
||||||
$ go install <path_to_repo>
|
docker build -t controldns/ctrld . -f docker/Dockerfile
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
||||||
|
# Usage
|
||||||
|
The cli is self documenting, so free free to run `--help` on any sub-command to get specific usages.
|
||||||
|
|
||||||
## Arguments
|
## Arguments
|
||||||
```
|
```
|
||||||
|
__ .__ .___
|
||||||
|
_____/ |________| | __| _/
|
||||||
|
_/ ___\ __\_ __ \ | / __ |
|
||||||
|
\ \___| | | | \/ |__/ /_/ |
|
||||||
|
\___ >__| |__| |____/\____ |
|
||||||
|
\/ dns forwarding proxy \/
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
ctrld [command]
|
ctrld [command]
|
||||||
|
|
||||||
Available Commands:
|
Available Commands:
|
||||||
help Help about any command
|
|
||||||
interfaces Manage Interface DNS settings
|
|
||||||
run Run the DNS proxy server
|
run Run the DNS proxy server
|
||||||
|
start Quick start service and configure DNS on interface
|
||||||
|
stop Quick stop service and remove DNS from interface
|
||||||
|
restart Restart the ctrld service
|
||||||
|
reload Reload the ctrld service
|
||||||
|
status Show status of the ctrld service
|
||||||
|
uninstall Stop and uninstall the ctrld service
|
||||||
|
service Manage ctrld service
|
||||||
|
clients Manage clients
|
||||||
|
upgrade Upgrading ctrld to latest version
|
||||||
|
log Manage runtime debug logs
|
||||||
|
|
||||||
Flags:
|
Flags:
|
||||||
-h, --help help for ctrld
|
-h, --help help for ctrld
|
||||||
-j, --json json output
|
-s, --silent do not write any log output
|
||||||
-v, --verbose verbose log output
|
-v, --verbose count verbose log output, "-v" basic logging, "-vv" debug level logging
|
||||||
--version version for ctrld
|
--version version for ctrld
|
||||||
|
|
||||||
Use "ctrld [command] --help" for more information about a command.
|
Use "ctrld [command] --help" for more information about a command.
|
||||||
```
|
```
|
||||||
## Usage
|
|
||||||
To start the server with default configuration, simply run: `ctrld run`. This will create a generic `config.toml` file in the working directory and start the service.
|
## Basic Run Mode
|
||||||
1. Start the server
|
This is the most basic way to run `ctrld`, in foreground mode. Unless you already have a config file, a default one will be generated.
|
||||||
```
|
|
||||||
$ sudo ./ctrld run
|
### Command
|
||||||
|
|
||||||
|
Windows (Admin Shell)
|
||||||
|
```shell
|
||||||
|
ctrld.exe run
|
||||||
```
|
```
|
||||||
|
|
||||||
2. Run a test query using a DNS client, for example, `dig`:
|
Linux or Macos
|
||||||
|
```shell
|
||||||
|
sudo ctrld run
|
||||||
|
```
|
||||||
|
|
||||||
|
You can then run a test query using a DNS client, for example, `dig`:
|
||||||
```
|
```
|
||||||
$ dig verify.controld.com @127.0.0.1 +short
|
$ dig verify.controld.com @127.0.0.1 +short
|
||||||
api.controld.com.
|
api.controld.com.
|
||||||
147.185.34.1
|
147.185.34.1
|
||||||
```
|
```
|
||||||
|
|
||||||
If `verify.controld.com` resolves, you're successfully using the default Control D upstream.
|
If `verify.controld.com` resolves, you're successfully using the default Control D upstream. From here, you can start editing the config file that was generated. To enforce a new config, restart the server.
|
||||||
|
|
||||||
|
## Service Mode
|
||||||
|
This mode will run the application as a background system service on any Windows, MacOS, Linux, FreeBSD distribution or supported router. This will create a generic `ctrld.toml` file in the **C:\ControlD** directory (on Windows) or `/etc/controld/` (almost everywhere else), start the system service, and **configure the listener on all physical network interface**. Service will start on OS boot.
|
||||||
|
|
||||||
## Configuration
|
When Control D upstreams are used on a router type device, `ctrld` will [relay your network topology](https://docs.controld.com/docs/device-clients) to Control D (LAN IPs, MAC addresses, and hostnames), and you will be able to see your LAN devices in the web panel, view analytics and apply unique profiles to them.
|
||||||
### Example
|
|
||||||
- Start `listener.0` on 127.0.0.1:53
|
|
||||||
- Accept queries from any source address
|
|
||||||
- Send all queries to `upstream.0` via DoH protocol
|
|
||||||
|
|
||||||
### Default Config
|
### Command
|
||||||
```toml
|
|
||||||
|
Windows (Admin Shell)
|
||||||
|
```shell
|
||||||
|
ctrld.exe start
|
||||||
|
```
|
||||||
|
|
||||||
|
Linux or Macos
|
||||||
|
```
|
||||||
|
sudo ctrld start
|
||||||
|
```
|
||||||
|
|
||||||
|
If `ctrld` is not in your system path (you installed it manually), you will need to run the above commands from the directory where you installed `ctrld`.
|
||||||
|
|
||||||
|
In order to stop the service, and restore your DNS to original state, simply run `ctrld stop`. If you wish to stop and uninstall the service permanently, run `ctrld uninstall`.
|
||||||
|
|
||||||
|
## Unmanaged Service Mode
|
||||||
|
This mode functions similarly to the "Service Mode" above except it will simply start a system service and the config defined listeners, but **will not make any changes to any network interfaces**. You can then set the `ctrld` listener(s) IP on the desired network interfaces manually.
|
||||||
|
|
||||||
|
### Command
|
||||||
|
|
||||||
|
Windows (Admin Shell)
|
||||||
|
```shell
|
||||||
|
ctrld.exe service start
|
||||||
|
```
|
||||||
|
|
||||||
|
Linux or Macos
|
||||||
|
```shell
|
||||||
|
sudo ctrld service start
|
||||||
|
```
|
||||||
|
|
||||||
|
# Configuration
|
||||||
|
`ctrld` can be configured in variety of different ways, which include: API, local config file or via cli launch args.
|
||||||
|
|
||||||
|
## API Based Auto Configuration
|
||||||
|
Application can be started with a specific Control D resolver config, instead of the default one. Simply supply your Resolver ID with a `--cd` flag, when using the `start` (service) mode. In this mode, the application will automatically choose a non-conflicting IP and/or port and configure itself as the upstream to whatever process is running on port 53 (like dnsmasq or Windows DNS Server). This mode is used when the 1 liner installer command from the Control D onboarding guide is executed.
|
||||||
|
|
||||||
|
The following command will use your own personal Control D Device resolver, and start the application in service mode. Your resolver ID is displayed on the "Show Resolvers" screen for the relevant Control D Endpoint.
|
||||||
|
|
||||||
|
Windows (Admin Shell)
|
||||||
|
```shell
|
||||||
|
ctrld.exe start --cd abcd1234
|
||||||
|
```
|
||||||
|
|
||||||
|
Linux or Macos
|
||||||
|
```shell
|
||||||
|
sudo ctrld start --cd abcd1234
|
||||||
|
```
|
||||||
|
|
||||||
|
Once you run the above command, the following things will happen:
|
||||||
|
- You resolver configuration will be fetched from the API, and config file templated with the resolver data
|
||||||
|
- Application will start as a service, and keep running (even after reboot) until you run the `stop` or `uninstall` sub-commands
|
||||||
|
- All physical network interface will be updated to use the listener started by the service or dnsmasq upstream will be switched to `ctrld`
|
||||||
|
- All DNS queries will be sent to the listener
|
||||||
|
|
||||||
|
## Manual Configuration
|
||||||
|
`ctrld` is entirely config driven and can be configured in many different ways, please see [Configuration Docs](docs/config.md).
|
||||||
|
|
||||||
|
### Example
|
||||||
|
```toml
|
||||||
[listener]
|
[listener]
|
||||||
|
|
||||||
[listener.0]
|
[listener.0]
|
||||||
ip = "127.0.0.1"
|
ip = '0.0.0.0'
|
||||||
port = 53
|
port = 53
|
||||||
restricted = false
|
|
||||||
|
|
||||||
[network]
|
[network]
|
||||||
|
|
||||||
@@ -96,10 +237,6 @@ If `verify.controld.com` resolves, you're successfully using the default Control
|
|||||||
cidrs = ["0.0.0.0/0"]
|
cidrs = ["0.0.0.0/0"]
|
||||||
name = "Network 0"
|
name = "Network 0"
|
||||||
|
|
||||||
[service]
|
|
||||||
log_level = "info"
|
|
||||||
log_path = ""
|
|
||||||
|
|
||||||
[upstream]
|
[upstream]
|
||||||
|
|
||||||
[upstream.0]
|
[upstream.0]
|
||||||
@@ -108,25 +245,26 @@ If `verify.controld.com` resolves, you're successfully using the default Control
|
|||||||
name = "Control D - Anti-Malware"
|
name = "Control D - Anti-Malware"
|
||||||
timeout = 5000
|
timeout = 5000
|
||||||
type = "doh"
|
type = "doh"
|
||||||
|
|
||||||
[upstream.1]
|
|
||||||
bootstrap_ip = "76.76.2.11"
|
|
||||||
endpoint = "p2.freedns.controld.com"
|
|
||||||
name = "Control D - No Ads"
|
|
||||||
timeout = 3000
|
|
||||||
type = "doq"
|
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Advanced
|
The above basic config will:
|
||||||
The above is the most basic example, which will work out of the box. If you're looking to do advanced configurations using policies, see [Configuration Docs](docs/config.md) for complete documentation of the config file.
|
- Start listener on 0.0.0.0:53
|
||||||
|
- Accept queries from any source address
|
||||||
|
- Send all queries to `https://freedns.controld.com/p1` using DoH protocol
|
||||||
|
|
||||||
|
## CLI Args
|
||||||
|
If you're unable to use a config file, `ctrld` can be be supplied with basic configuration via launch arguments, in [Ephemeral Mode](docs/ephemeral_mode.md).
|
||||||
|
|
||||||
|
### Example
|
||||||
|
```
|
||||||
|
ctrld run --listen=127.0.0.1:53 --primary_upstream=https://freedns.controld.com/p2 --secondary_upstream=10.0.10.1:53 --domains=*.company.int,very-secure.local --log /path/to/log.log
|
||||||
|
```
|
||||||
|
|
||||||
|
The above will start a foreground process and:
|
||||||
|
- Listen on `127.0.0.1:53` for DNS queries
|
||||||
|
- Forward all queries to `https://freedns.controld.com/p2` using DoH protocol, while...
|
||||||
|
- Excluding `*.company.int` and `very-secure.local` matching queries, that are forwarded to `10.0.10.1:53`
|
||||||
|
- Write a debug log to `/path/to/log.log`
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
See [Contribution Guideline](./docs/contributing.md)
|
See [Contribution Guideline](./docs/contributing.md)
|
||||||
|
|
||||||
## Roadmap
|
|
||||||
The following functionality is on the roadmap and will be available in future releases.
|
|
||||||
- Prometheus metrics exporter
|
|
||||||
- Local caching
|
|
||||||
- Service self-installation
|
|
||||||
|
|||||||
22
client_info.go
Normal file
22
client_info.go
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
package ctrld
|
||||||
|
|
||||||
|
// ClientInfoCtxKey is the context key to store client info.
|
||||||
|
type ClientInfoCtxKey struct{}
|
||||||
|
|
||||||
|
// ClientInfo represents ctrld's clients information.
|
||||||
|
type ClientInfo struct {
|
||||||
|
Mac string
|
||||||
|
IP string
|
||||||
|
Hostname string
|
||||||
|
Self bool
|
||||||
|
ClientIDPref string
|
||||||
|
}
|
||||||
|
|
||||||
|
// LeaseFileFormat specifies the format of DHCP lease file.
|
||||||
|
type LeaseFileFormat string
|
||||||
|
|
||||||
|
const (
|
||||||
|
Dnsmasq LeaseFileFormat = "dnsmasq"
|
||||||
|
IscDhcpd LeaseFileFormat = "isc-dhcpd"
|
||||||
|
KeaDHCP4 LeaseFileFormat = "kea-dhcp4"
|
||||||
|
)
|
||||||
4
client_info_darwin.go
Normal file
4
client_info_darwin.go
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
package ctrld
|
||||||
|
|
||||||
|
// SelfDiscover reports whether ctrld should only do self discover.
|
||||||
|
func SelfDiscover() bool { return true }
|
||||||
6
client_info_others.go
Normal file
6
client_info_others.go
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
//go:build !windows && !darwin
|
||||||
|
|
||||||
|
package ctrld
|
||||||
|
|
||||||
|
// SelfDiscover reports whether ctrld should only do self discover.
|
||||||
|
func SelfDiscover() bool { return false }
|
||||||
18
client_info_windows.go
Normal file
18
client_info_windows.go
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
package ctrld
|
||||||
|
|
||||||
|
import (
|
||||||
|
"golang.org/x/sys/windows"
|
||||||
|
)
|
||||||
|
|
||||||
|
// isWindowsWorkStation reports whether ctrld was run on a Windows workstation machine.
|
||||||
|
func isWindowsWorkStation() bool {
|
||||||
|
// From https://learn.microsoft.com/en-us/windows/win32/api/winnt/ns-winnt-osversioninfoexa
|
||||||
|
const VER_NT_WORKSTATION = 0x0000001
|
||||||
|
osvi := windows.RtlGetVersion()
|
||||||
|
return osvi.ProductType == VER_NT_WORKSTATION
|
||||||
|
}
|
||||||
|
|
||||||
|
// SelfDiscover reports whether ctrld should only do self discover.
|
||||||
|
func SelfDiscover() bool {
|
||||||
|
return isWindowsWorkStation()
|
||||||
|
}
|
||||||
15
cmd/cli/ad_others.go
Normal file
15
cmd/cli/ad_others.go
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
//go:build !windows
|
||||||
|
|
||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/Control-D-Inc/ctrld"
|
||||||
|
)
|
||||||
|
|
||||||
|
// addExtraSplitDnsRule adds split DNS rule if present.
|
||||||
|
func addExtraSplitDnsRule(_ *ctrld.Config) bool { return false }
|
||||||
|
|
||||||
|
// getActiveDirectoryDomain returns AD domain name of this computer.
|
||||||
|
func getActiveDirectoryDomain() (string, error) {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
73
cmd/cli/ad_windows.go
Normal file
73
cmd/cli/ad_windows.go
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/microsoft/wmi/pkg/base/host"
|
||||||
|
hh "github.com/microsoft/wmi/pkg/hardware/host"
|
||||||
|
|
||||||
|
"github.com/Control-D-Inc/ctrld"
|
||||||
|
)
|
||||||
|
|
||||||
|
// addExtraSplitDnsRule adds split DNS rule for domain if it's part of active directory.
|
||||||
|
func addExtraSplitDnsRule(cfg *ctrld.Config) bool {
|
||||||
|
domain, err := getActiveDirectoryDomain()
|
||||||
|
if err != nil {
|
||||||
|
mainLog.Load().Debug().Msgf("unable to get active directory domain: %v", err)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if domain == "" {
|
||||||
|
mainLog.Load().Debug().Msg("no active directory domain found")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
// Network rules are lowercase during toml config marshaling,
|
||||||
|
// lowercase the domain here too for consistency.
|
||||||
|
domain = strings.ToLower(domain)
|
||||||
|
domainRuleAdded := addSplitDnsRule(cfg, domain)
|
||||||
|
wildcardDomainRuleRuleAdded := addSplitDnsRule(cfg, "*."+strings.TrimPrefix(domain, "."))
|
||||||
|
return domainRuleAdded || wildcardDomainRuleRuleAdded
|
||||||
|
}
|
||||||
|
|
||||||
|
// addSplitDnsRule adds split-rule for given domain if there's no existed rule.
|
||||||
|
// The return value indicates whether the split-rule was added or not.
|
||||||
|
func addSplitDnsRule(cfg *ctrld.Config, domain string) bool {
|
||||||
|
for n, lc := range cfg.Listener {
|
||||||
|
if lc.Policy == nil {
|
||||||
|
lc.Policy = &ctrld.ListenerPolicyConfig{}
|
||||||
|
}
|
||||||
|
for _, rule := range lc.Policy.Rules {
|
||||||
|
if _, ok := rule[domain]; ok {
|
||||||
|
mainLog.Load().Debug().Msgf("split-rule %q already existed for listener.%s", domain, n)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
mainLog.Load().Debug().Msgf("adding split-rule %q for listener.%s", domain, n)
|
||||||
|
lc.Policy.Rules = append(lc.Policy.Rules, ctrld.Rule{domain: []string{}})
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// getActiveDirectoryDomain returns AD domain name of this computer.
|
||||||
|
func getActiveDirectoryDomain() (string, error) {
|
||||||
|
log.SetOutput(io.Discard)
|
||||||
|
defer log.SetOutput(os.Stderr)
|
||||||
|
whost := host.NewWmiLocalHost()
|
||||||
|
cs, err := hh.GetComputerSystem(whost)
|
||||||
|
if cs != nil {
|
||||||
|
defer cs.Close()
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
pod, err := cs.GetPropertyPartOfDomain()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if pod {
|
||||||
|
return cs.GetPropertyDomain()
|
||||||
|
}
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
71
cmd/cli/ad_windows_test.go
Normal file
71
cmd/cli/ad_windows_test.go
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/Control-D-Inc/ctrld"
|
||||||
|
"github.com/Control-D-Inc/ctrld/testhelper"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Test_getActiveDirectoryDomain(t *testing.T) {
|
||||||
|
start := time.Now()
|
||||||
|
domain, err := getActiveDirectoryDomain()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Logf("Using Windows API takes: %d", time.Since(start).Milliseconds())
|
||||||
|
|
||||||
|
start = time.Now()
|
||||||
|
domainPowershell, err := getActiveDirectoryDomainPowershell()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Logf("Using Powershell takes: %d", time.Since(start).Milliseconds())
|
||||||
|
|
||||||
|
if domain != domainPowershell {
|
||||||
|
t.Fatalf("result mismatch, want: %v, got: %v", domainPowershell, domain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func getActiveDirectoryDomainPowershell() (string, error) {
|
||||||
|
cmd := "$obj = Get-WmiObject Win32_ComputerSystem; if ($obj.PartOfDomain) { $obj.Domain }"
|
||||||
|
output, err := powershell(cmd)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to get domain name: %w, output:\n\n%s", err, string(output))
|
||||||
|
}
|
||||||
|
return string(output), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func Test_addSplitDnsRule(t *testing.T) {
|
||||||
|
newCfg := func(domains ...string) *ctrld.Config {
|
||||||
|
cfg := testhelper.SampleConfig(t)
|
||||||
|
lc := cfg.Listener["0"]
|
||||||
|
for _, domain := range domains {
|
||||||
|
lc.Policy.Rules = append(lc.Policy.Rules, ctrld.Rule{domain: []string{}})
|
||||||
|
}
|
||||||
|
return cfg
|
||||||
|
}
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
cfg *ctrld.Config
|
||||||
|
domain string
|
||||||
|
added bool
|
||||||
|
}{
|
||||||
|
{"added", newCfg(), "example.com", true},
|
||||||
|
{"TLD existed", newCfg("example.com"), "*.example.com", true},
|
||||||
|
{"wildcard existed", newCfg("*.example.com"), "example.com", true},
|
||||||
|
{"not added TLD", newCfg("example.com", "*.example.com"), "example.com", false},
|
||||||
|
{"not added wildcard", newCfg("example.com", "*.example.com"), "*.example.com", false},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
tc := tc
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
added := addSplitDnsRule(tc.cfg, tc.domain)
|
||||||
|
assert.Equal(t, tc.added, added)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
5
cmd/cli/cgo.go
Normal file
5
cmd/cli/cgo.go
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
//go:build cgo
|
||||||
|
|
||||||
|
package cli
|
||||||
|
|
||||||
|
const cgoEnabled = true
|
||||||
1926
cmd/cli/cli.go
Normal file
1926
cmd/cli/cli.go
Normal file
File diff suppressed because it is too large
Load Diff
46
cmd/cli/cli_test.go
Normal file
46
cmd/cli/cli_test.go
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Test_writeConfigFile(t *testing.T) {
|
||||||
|
tmpdir := t.TempDir()
|
||||||
|
// simulate --config CLI flag by setting configPath manually.
|
||||||
|
configPath = filepath.Join(tmpdir, "ctrld.toml")
|
||||||
|
_, err := os.Stat(configPath)
|
||||||
|
assert.True(t, os.IsNotExist(err))
|
||||||
|
|
||||||
|
assert.NoError(t, writeConfigFile(&cfg))
|
||||||
|
|
||||||
|
_, err = os.Stat(configPath)
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func Test_isStableVersion(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
ver string
|
||||||
|
isStable bool
|
||||||
|
}{
|
||||||
|
{"stable", "v1.3.5", true},
|
||||||
|
{"pre", "v1.3.5-next", false},
|
||||||
|
{"pre with commit hash", "v1.3.5-next-asdf", false},
|
||||||
|
{"dev", "dev", false},
|
||||||
|
{"empty", "dev", false},
|
||||||
|
}
|
||||||
|
for _, tc := range tests {
|
||||||
|
tc := tc
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
if got := isStableVersion(tc.ver); got != tc.isStable {
|
||||||
|
t.Errorf("unexpected result for %s, want: %v, got: %v", tc.ver, tc.isStable, got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
1383
cmd/cli/commands.go
Normal file
1383
cmd/cli/commands.go
Normal file
File diff suppressed because it is too large
Load Diff
51
cmd/cli/conn.go
Normal file
51
cmd/cli/conn.go
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// logConn wraps a net.Conn, override the Write behavior.
|
||||||
|
// runCmd uses this wrapper, so as long as startCmd finished,
|
||||||
|
// ctrld log won't be flushed with un-necessary write errors.
|
||||||
|
type logConn struct {
|
||||||
|
conn net.Conn
|
||||||
|
}
|
||||||
|
|
||||||
|
func (lc *logConn) Read(b []byte) (n int, err error) {
|
||||||
|
return lc.conn.Read(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (lc *logConn) Close() error {
|
||||||
|
return lc.conn.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (lc *logConn) LocalAddr() net.Addr {
|
||||||
|
return lc.conn.LocalAddr()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (lc *logConn) RemoteAddr() net.Addr {
|
||||||
|
return lc.conn.RemoteAddr()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (lc *logConn) SetDeadline(t time.Time) error {
|
||||||
|
return lc.conn.SetDeadline(t)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (lc *logConn) SetReadDeadline(t time.Time) error {
|
||||||
|
return lc.conn.SetReadDeadline(t)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (lc *logConn) SetWriteDeadline(t time.Time) error {
|
||||||
|
return lc.conn.SetWriteDeadline(t)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (lc *logConn) Write(b []byte) (int, error) {
|
||||||
|
// Write performs writes with underlying net.Conn, ignore any errors happen.
|
||||||
|
// "ctrld run" command use this wrapper to report errors to "ctrld start".
|
||||||
|
// If no error occurred, "ctrld start" may finish before "ctrld run" attempt
|
||||||
|
// to close the connection, so ignore errors conservatively here, prevent
|
||||||
|
// un-necessary error "write to closed connection" flushed to ctrld log.
|
||||||
|
_, _ = lc.conn.Write(b)
|
||||||
|
return len(b), nil
|
||||||
|
}
|
||||||
38
cmd/cli/control_client.go
Normal file
38
cmd/cli/control_client.go
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type controlClient struct {
|
||||||
|
c *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
func newControlClient(addr string) *controlClient {
|
||||||
|
return &controlClient{c: &http.Client{
|
||||||
|
Transport: &http.Transport{
|
||||||
|
DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
|
||||||
|
d := net.Dialer{}
|
||||||
|
return d.DialContext(ctx, "unix", addr)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Timeout: time.Second * 30,
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *controlClient) post(path string, data io.Reader) (*http.Response, error) {
|
||||||
|
// for log/send, set the timeout to 5 minutes
|
||||||
|
if path == sendLogsPath {
|
||||||
|
c.c.Timeout = time.Minute * 5
|
||||||
|
}
|
||||||
|
return c.c.Post("http://unix"+path, contentTypeJson, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// deactivationRequest represents request for validating deactivation pin.
|
||||||
|
type deactivationRequest struct {
|
||||||
|
Pin int64 `json:"pin"`
|
||||||
|
}
|
||||||
344
cmd/cli/control_server.go
Normal file
344
cmd/cli/control_server.go
Normal file
@@ -0,0 +1,344 @@
|
|||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"reflect"
|
||||||
|
"sort"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/kardianos/service"
|
||||||
|
dto "github.com/prometheus/client_model/go"
|
||||||
|
|
||||||
|
"github.com/Control-D-Inc/ctrld"
|
||||||
|
"github.com/Control-D-Inc/ctrld/internal/controld"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
contentTypeJson = "application/json"
|
||||||
|
listClientsPath = "/clients"
|
||||||
|
startedPath = "/started"
|
||||||
|
reloadPath = "/reload"
|
||||||
|
deactivationPath = "/deactivation"
|
||||||
|
cdPath = "/cd"
|
||||||
|
ifacePath = "/iface"
|
||||||
|
viewLogsPath = "/log/view"
|
||||||
|
sendLogsPath = "/log/send"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ifaceResponse struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
All bool `json:"all"`
|
||||||
|
OK bool `json:"ok"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type controlServer struct {
|
||||||
|
server *http.Server
|
||||||
|
mux *http.ServeMux
|
||||||
|
addr string
|
||||||
|
}
|
||||||
|
|
||||||
|
func newControlServer(addr string) (*controlServer, error) {
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
s := &controlServer{
|
||||||
|
server: &http.Server{Handler: mux},
|
||||||
|
mux: mux,
|
||||||
|
}
|
||||||
|
s.addr = addr
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *controlServer) start() error {
|
||||||
|
_ = os.Remove(s.addr)
|
||||||
|
unixListener, err := net.Listen("unix", s.addr)
|
||||||
|
if l, ok := unixListener.(*net.UnixListener); ok {
|
||||||
|
l.SetUnlinkOnClose(true)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
go s.server.Serve(unixListener)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *controlServer) stop() error {
|
||||||
|
_ = os.Remove(s.addr)
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), time.Second*2)
|
||||||
|
defer cancel()
|
||||||
|
return s.server.Shutdown(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *controlServer) register(pattern string, handler http.Handler) {
|
||||||
|
s.mux.Handle(pattern, jsonResponse(handler))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *prog) registerControlServerHandler() {
|
||||||
|
p.cs.register(listClientsPath, http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) {
|
||||||
|
mainLog.Load().Debug().Msg("handling list clients request")
|
||||||
|
|
||||||
|
clients := p.ciTable.ListClients()
|
||||||
|
mainLog.Load().Debug().Int("client_count", len(clients)).Msg("retrieved clients list")
|
||||||
|
|
||||||
|
sort.Slice(clients, func(i, j int) bool {
|
||||||
|
return clients[i].IP.Less(clients[j].IP)
|
||||||
|
})
|
||||||
|
mainLog.Load().Debug().Msg("sorted clients by IP address")
|
||||||
|
|
||||||
|
if p.metricsQueryStats.Load() {
|
||||||
|
mainLog.Load().Debug().Msg("metrics query stats enabled, collecting query counts")
|
||||||
|
|
||||||
|
for idx, client := range clients {
|
||||||
|
mainLog.Load().Debug().
|
||||||
|
Int("index", idx).
|
||||||
|
Str("ip", client.IP.String()).
|
||||||
|
Str("mac", client.Mac).
|
||||||
|
Str("hostname", client.Hostname).
|
||||||
|
Msg("processing client metrics")
|
||||||
|
|
||||||
|
client.IncludeQueryCount = true
|
||||||
|
dm := &dto.Metric{}
|
||||||
|
|
||||||
|
if statsClientQueriesCount.MetricVec == nil {
|
||||||
|
mainLog.Load().Debug().
|
||||||
|
Str("client_ip", client.IP.String()).
|
||||||
|
Msg("skipping metrics collection: MetricVec is nil")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
m, err := statsClientQueriesCount.MetricVec.GetMetricWithLabelValues(
|
||||||
|
client.IP.String(),
|
||||||
|
client.Mac,
|
||||||
|
client.Hostname,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
mainLog.Load().Debug().
|
||||||
|
Err(err).
|
||||||
|
Str("client_ip", client.IP.String()).
|
||||||
|
Str("mac", client.Mac).
|
||||||
|
Str("hostname", client.Hostname).
|
||||||
|
Msg("failed to get metrics for client")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := m.Write(dm); err == nil && dm.Counter != nil {
|
||||||
|
client.QueryCount = int64(dm.Counter.GetValue())
|
||||||
|
mainLog.Load().Debug().
|
||||||
|
Str("client_ip", client.IP.String()).
|
||||||
|
Int64("query_count", client.QueryCount).
|
||||||
|
Msg("successfully collected query count")
|
||||||
|
} else if err != nil {
|
||||||
|
mainLog.Load().Debug().
|
||||||
|
Err(err).
|
||||||
|
Str("client_ip", client.IP.String()).
|
||||||
|
Msg("failed to write metric")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
mainLog.Load().Debug().Msg("metrics query stats disabled, skipping query counts")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.NewEncoder(w).Encode(&clients); err != nil {
|
||||||
|
mainLog.Load().Error().
|
||||||
|
Err(err).
|
||||||
|
Int("client_count", len(clients)).
|
||||||
|
Msg("failed to encode clients response")
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
mainLog.Load().Debug().
|
||||||
|
Int("client_count", len(clients)).
|
||||||
|
Msg("successfully sent clients list response")
|
||||||
|
}))
|
||||||
|
p.cs.register(startedPath, http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) {
|
||||||
|
select {
|
||||||
|
case <-p.onStartedDone:
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
case <-time.After(10 * time.Second):
|
||||||
|
w.WriteHeader(http.StatusRequestTimeout)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
p.cs.register(reloadPath, http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) {
|
||||||
|
listeners := make(map[string]*ctrld.ListenerConfig)
|
||||||
|
p.mu.Lock()
|
||||||
|
for k, v := range p.cfg.Listener {
|
||||||
|
listeners[k] = &ctrld.ListenerConfig{
|
||||||
|
IP: v.IP,
|
||||||
|
Port: v.Port,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
oldSvc := p.cfg.Service
|
||||||
|
p.mu.Unlock()
|
||||||
|
if err := p.sendReloadSignal(); err != nil {
|
||||||
|
mainLog.Load().Err(err).Msg("could not send reload signal")
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-p.reloadDoneCh:
|
||||||
|
case <-time.After(5 * time.Second):
|
||||||
|
http.Error(w, "timeout waiting for ctrld reload", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
p.mu.Lock()
|
||||||
|
defer p.mu.Unlock()
|
||||||
|
|
||||||
|
// Checking for cases that we could not do a reload.
|
||||||
|
|
||||||
|
// 1. Listener config ip or port changes.
|
||||||
|
for k, v := range p.cfg.Listener {
|
||||||
|
l := listeners[k]
|
||||||
|
if l == nil || l.IP != v.IP || l.Port != v.Port {
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Service config changes.
|
||||||
|
if !reflect.DeepEqual(oldSvc, p.cfg.Service) {
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise, reload is done.
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}))
|
||||||
|
p.cs.register(deactivationPath, http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) {
|
||||||
|
// Non-cd mode always allowing deactivation.
|
||||||
|
if cdUID == "" {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-fetch pin code from API.
|
||||||
|
if rc, err := controld.FetchResolverConfig(cdUID, rootCmd.Version, cdDev); rc != nil {
|
||||||
|
if rc.DeactivationPin != nil {
|
||||||
|
cdDeactivationPin.Store(*rc.DeactivationPin)
|
||||||
|
} else {
|
||||||
|
cdDeactivationPin.Store(defaultDeactivationPin)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
mainLog.Load().Warn().Err(err).Msg("could not re-fetch deactivation pin code")
|
||||||
|
}
|
||||||
|
|
||||||
|
// If pin code not set, allowing deactivation.
|
||||||
|
if !deactivationPinSet() {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req deactivationRequest
|
||||||
|
if err := json.NewDecoder(request.Body).Decode(&req); err != nil {
|
||||||
|
w.WriteHeader(http.StatusPreconditionFailed)
|
||||||
|
mainLog.Load().Err(err).Msg("invalid deactivation request")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
code := http.StatusForbidden
|
||||||
|
switch req.Pin {
|
||||||
|
case cdDeactivationPin.Load():
|
||||||
|
code = http.StatusOK
|
||||||
|
select {
|
||||||
|
case p.pinCodeValidCh <- struct{}{}:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
case defaultDeactivationPin:
|
||||||
|
// If the pin code was set, but users do not provide --pin, return proper code to client.
|
||||||
|
code = http.StatusBadRequest
|
||||||
|
}
|
||||||
|
w.WriteHeader(code)
|
||||||
|
}))
|
||||||
|
p.cs.register(cdPath, http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) {
|
||||||
|
if cdUID != "" {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write([]byte(cdUID))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
}))
|
||||||
|
p.cs.register(ifacePath, http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) {
|
||||||
|
res := &ifaceResponse{Name: iface}
|
||||||
|
// p.setDNS is only called when running as a service
|
||||||
|
if !service.Interactive() {
|
||||||
|
<-p.csSetDnsDone
|
||||||
|
if p.csSetDnsOk {
|
||||||
|
res.Name = p.runningIface
|
||||||
|
res.All = p.requiredMultiNICsConfig
|
||||||
|
res.OK = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := json.NewEncoder(w).Encode(res); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
http.Error(w, fmt.Sprintf("could not marshal iface data: %v", err), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
p.cs.register(viewLogsPath, http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) {
|
||||||
|
lr, err := p.logReader()
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer lr.r.Close()
|
||||||
|
if lr.size == 0 {
|
||||||
|
w.WriteHeader(http.StatusMovedPermanently)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data, err := io.ReadAll(lr.r)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, fmt.Sprintf("could not read log: %v", err), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := json.NewEncoder(w).Encode(&logViewResponse{Data: string(data)}); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
http.Error(w, fmt.Sprintf("could not marshal log data: %v", err), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
p.cs.register(sendLogsPath, http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) {
|
||||||
|
if time.Since(p.internalLogSent) < logWriterSentInterval {
|
||||||
|
w.WriteHeader(http.StatusServiceUnavailable)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
r, err := p.logReader()
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if r.size == 0 {
|
||||||
|
w.WriteHeader(http.StatusMovedPermanently)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
req := &controld.LogsRequest{
|
||||||
|
UID: cdUID,
|
||||||
|
Data: r.r,
|
||||||
|
}
|
||||||
|
mainLog.Load().Debug().Msg("sending log file to ControlD server")
|
||||||
|
resp := logSentResponse{Size: r.size}
|
||||||
|
if err := controld.SendLogs(req, cdDev); err != nil {
|
||||||
|
mainLog.Load().Error().Msgf("could not send log file to ControlD server: %v", err)
|
||||||
|
resp.Error = err.Error()
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
} else {
|
||||||
|
mainLog.Load().Debug().Msg("sending log file successfully")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}
|
||||||
|
if err := json.NewEncoder(w).Encode(&resp); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
p.internalLogSent = time.Now()
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
func jsonResponse(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
54
cmd/cli/control_server_test.go
Normal file
54
cmd/cli/control_server_test.go
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestControlServer(t *testing.T) {
|
||||||
|
f, err := os.CreateTemp("", "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer os.Remove(f.Name())
|
||||||
|
f.Close()
|
||||||
|
|
||||||
|
s, err := newControlServer(f.Name())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
pattern := "/ping"
|
||||||
|
respBody := []byte("pong")
|
||||||
|
s.register(pattern, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
_, _ = w.Write(respBody)
|
||||||
|
}))
|
||||||
|
if err := s.start(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
c := newControlClient(f.Name())
|
||||||
|
resp, err := c.post(pattern, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
t.Fatalf("unepxected response code: %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
if ct := resp.Header.Get("content-type"); ct != contentTypeJson {
|
||||||
|
t.Fatalf("unexpected content type: %s", ct)
|
||||||
|
}
|
||||||
|
buf, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !bytes.Equal(buf, respBody) {
|
||||||
|
t.Errorf("unexpected response body, want: %q, got: %q", string(respBody), string(buf))
|
||||||
|
}
|
||||||
|
if err := s.stop(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
4
cmd/cli/dns.go
Normal file
4
cmd/cli/dns.go
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
package cli
|
||||||
|
|
||||||
|
//lint:ignore U1000 use in os_linux.go
|
||||||
|
type getDNS func(iface string) []string
|
||||||
1635
cmd/cli/dns_proxy.go
Normal file
1635
cmd/cli/dns_proxy.go
Normal file
File diff suppressed because it is too large
Load Diff
466
cmd/cli/dns_proxy_test.go
Normal file
466
cmd/cli/dns_proxy_test.go
Normal file
@@ -0,0 +1,466 @@
|
|||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/miekg/dns"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"github.com/Control-D-Inc/ctrld"
|
||||||
|
"github.com/Control-D-Inc/ctrld/internal/dnscache"
|
||||||
|
"github.com/Control-D-Inc/ctrld/testhelper"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Test_wildcardMatches(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
wildcard string
|
||||||
|
domain string
|
||||||
|
match bool
|
||||||
|
}{
|
||||||
|
{"domain - prefix parent should not match", "*.windscribe.com", "windscribe.com", false},
|
||||||
|
{"domain - prefix", "*.windscribe.com", "anything.windscribe.com", true},
|
||||||
|
{"domain - prefix not match other s", "*.windscribe.com", "example.com", false},
|
||||||
|
{"domain - prefix not match s in name", "*.windscribe.com", "wwindscribe.com", false},
|
||||||
|
{"domain - suffix", "suffix.*", "suffix.windscribe.com", true},
|
||||||
|
{"domain - suffix not match other", "suffix.*", "suffix1.windscribe.com", false},
|
||||||
|
{"domain - both", "suffix.*.windscribe.com", "suffix.anything.windscribe.com", true},
|
||||||
|
{"domain - both not match", "suffix.*.windscribe.com", "suffix1.suffix.windscribe.com", false},
|
||||||
|
{"domain - case-insensitive", "*.WINDSCRIBE.com", "anything.windscribe.com", true},
|
||||||
|
{"mac - prefix", "*:98:05:b4:2b", "d4:67:98:05:b4:2b", true},
|
||||||
|
{"mac - prefix not match other s", "*:98:05:b4:2b", "0d:ba:54:09:94:2c", false},
|
||||||
|
{"mac - prefix not match s in name", "*:98:05:b4:2b", "e4:67:97:05:b4:2b", false},
|
||||||
|
{"mac - suffix", "d4:67:98:*", "d4:67:98:05:b4:2b", true},
|
||||||
|
{"mac - suffix not match other", "d4:67:98:*", "d4:67:97:15:b4:2b", false},
|
||||||
|
{"mac - both", "d4:67:98:*:b4:2b", "d4:67:98:05:b4:2b", true},
|
||||||
|
{"mac - both not match", "d4:67:98:*:b4:2b", "d4:67:97:05:c4:2b", false},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
tc := tc
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
if got := wildcardMatches(tc.wildcard, tc.domain); got != tc.match {
|
||||||
|
t.Errorf("unexpected result, wildcard: %s, domain: %s, want: %v, got: %v", tc.wildcard, tc.domain, tc.match, got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func Test_canonicalName(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
domain string
|
||||||
|
canonical string
|
||||||
|
}{
|
||||||
|
{"fqdn to canonical", "windscribe.com.", "windscribe.com"},
|
||||||
|
{"already canonical", "windscribe.com", "windscribe.com"},
|
||||||
|
{"case insensitive", "Windscribe.Com.", "windscribe.com"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
tc := tc
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
if got := canonicalName(tc.domain); got != tc.canonical {
|
||||||
|
t.Errorf("unexpected result, want: %s, got: %s", tc.canonical, got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func Test_prog_upstreamFor(t *testing.T) {
|
||||||
|
cfg := testhelper.SampleConfig(t)
|
||||||
|
cfg.Service.LeakOnUpstreamFailure = func(v bool) *bool { return &v }(false)
|
||||||
|
p := &prog{cfg: cfg}
|
||||||
|
p.um = newUpstreamMonitor(p.cfg)
|
||||||
|
p.lanLoopGuard = newLoopGuard()
|
||||||
|
p.ptrLoopGuard = newLoopGuard()
|
||||||
|
for _, nc := range p.cfg.Network {
|
||||||
|
for _, cidr := range nc.Cidrs {
|
||||||
|
_, ipNet, err := net.ParseCIDR(cidr)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
nc.IPNets = append(nc.IPNets, ipNet)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
ip string
|
||||||
|
mac string
|
||||||
|
defaultUpstreamNum string
|
||||||
|
lc *ctrld.ListenerConfig
|
||||||
|
domain string
|
||||||
|
upstreams []string
|
||||||
|
matched bool
|
||||||
|
testLogMsg string
|
||||||
|
}{
|
||||||
|
{"Policy map matches", "192.168.0.1:0", "", "0", p.cfg.Listener["0"], "abc.xyz", []string{"upstream.1", "upstream.0"}, true, ""},
|
||||||
|
{"Policy split matches", "192.168.0.1:0", "", "0", p.cfg.Listener["0"], "abc.ru", []string{"upstream.1"}, true, ""},
|
||||||
|
{"Policy map for other network matches", "192.168.1.2:0", "", "0", p.cfg.Listener["0"], "abc.xyz", []string{"upstream.0"}, true, ""},
|
||||||
|
{"No policy map for listener", "192.168.1.2:0", "", "1", p.cfg.Listener["1"], "abc.ru", []string{"upstream.1"}, false, ""},
|
||||||
|
{"unenforced loging", "192.168.1.2:0", "", "0", p.cfg.Listener["0"], "abc.ru", []string{"upstream.1"}, true, "My Policy, network.1 (unenforced), *.ru -> [upstream.1]"},
|
||||||
|
{"Policy Macs matches upper", "192.168.0.1:0", "14:45:A0:67:83:0A", "0", p.cfg.Listener["0"], "abc.xyz", []string{"upstream.2"}, true, "14:45:a0:67:83:0a"},
|
||||||
|
{"Policy Macs matches lower", "192.168.0.1:0", "14:54:4a:8e:08:2d", "0", p.cfg.Listener["0"], "abc.xyz", []string{"upstream.2"}, true, "14:54:4a:8e:08:2d"},
|
||||||
|
{"Policy Macs matches case-insensitive", "192.168.0.1:0", "14:54:4A:8E:08:2D", "0", p.cfg.Listener["0"], "abc.xyz", []string{"upstream.2"}, true, "14:54:4a:8e:08:2d"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
for _, network := range []string{"udp", "tcp"} {
|
||||||
|
var (
|
||||||
|
addr net.Addr
|
||||||
|
err error
|
||||||
|
)
|
||||||
|
switch network {
|
||||||
|
case "udp":
|
||||||
|
addr, err = net.ResolveUDPAddr(network, tc.ip)
|
||||||
|
case "tcp":
|
||||||
|
addr, err = net.ResolveTCPAddr(network, tc.ip)
|
||||||
|
}
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, addr)
|
||||||
|
ctx := context.WithValue(context.Background(), ctrld.ReqIdCtxKey{}, requestID())
|
||||||
|
ufr := p.upstreamFor(ctx, tc.defaultUpstreamNum, tc.lc, addr, tc.mac, tc.domain)
|
||||||
|
p.proxy(ctx, &proxyRequest{
|
||||||
|
msg: newDnsMsgWithHostname("foo", dns.TypeA),
|
||||||
|
ufr: ufr,
|
||||||
|
})
|
||||||
|
assert.Equal(t, tc.matched, ufr.matched)
|
||||||
|
assert.Equal(t, tc.upstreams, ufr.upstreams)
|
||||||
|
if tc.testLogMsg != "" {
|
||||||
|
assert.Contains(t, logOutput.String(), tc.testLogMsg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCache(t *testing.T) {
|
||||||
|
cfg := testhelper.SampleConfig(t)
|
||||||
|
prog := &prog{cfg: cfg}
|
||||||
|
for _, nc := range prog.cfg.Network {
|
||||||
|
for _, cidr := range nc.Cidrs {
|
||||||
|
_, ipNet, err := net.ParseCIDR(cidr)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
nc.IPNets = append(nc.IPNets, ipNet)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cacher, err := dnscache.NewLRUCache(4096)
|
||||||
|
require.NoError(t, err)
|
||||||
|
prog.cache = cacher
|
||||||
|
|
||||||
|
msg := new(dns.Msg)
|
||||||
|
msg.SetQuestion("example.com", dns.TypeA)
|
||||||
|
msg.MsgHdr.RecursionDesired = true
|
||||||
|
answer1 := new(dns.Msg)
|
||||||
|
answer1.SetRcode(msg, dns.RcodeSuccess)
|
||||||
|
|
||||||
|
prog.cache.Add(dnscache.NewKey(msg, "upstream.1"), dnscache.NewValue(answer1, time.Now().Add(time.Minute)))
|
||||||
|
answer2 := new(dns.Msg)
|
||||||
|
answer2.SetRcode(msg, dns.RcodeRefused)
|
||||||
|
prog.cache.Add(dnscache.NewKey(msg, "upstream.0"), dnscache.NewValue(answer2, time.Now().Add(time.Minute)))
|
||||||
|
|
||||||
|
req1 := &proxyRequest{
|
||||||
|
msg: msg,
|
||||||
|
ci: nil,
|
||||||
|
failoverRcodes: nil,
|
||||||
|
ufr: &upstreamForResult{
|
||||||
|
upstreams: []string{"upstream.1"},
|
||||||
|
matchedPolicy: "",
|
||||||
|
matchedNetwork: "",
|
||||||
|
matchedRule: "",
|
||||||
|
matched: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
req2 := &proxyRequest{
|
||||||
|
msg: msg,
|
||||||
|
ci: nil,
|
||||||
|
failoverRcodes: nil,
|
||||||
|
ufr: &upstreamForResult{
|
||||||
|
upstreams: []string{"upstream.0"},
|
||||||
|
matchedPolicy: "",
|
||||||
|
matchedNetwork: "",
|
||||||
|
matchedRule: "",
|
||||||
|
matched: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
got1 := prog.proxy(context.Background(), req1)
|
||||||
|
got2 := prog.proxy(context.Background(), req2)
|
||||||
|
assert.NotSame(t, got1, got2)
|
||||||
|
assert.Equal(t, answer1.Rcode, got1.answer.Rcode)
|
||||||
|
assert.Equal(t, answer2.Rcode, got2.answer.Rcode)
|
||||||
|
}
|
||||||
|
|
||||||
|
func Test_ipAndMacFromMsg(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
ip string
|
||||||
|
wantIp bool
|
||||||
|
mac string
|
||||||
|
wantMac bool
|
||||||
|
}{
|
||||||
|
{"has ip v4 and mac", "1.2.3.4", true, "4c:20:b8:ab:87:1b", true},
|
||||||
|
{"has ip v6 and mac", "2606:1a40:3::1", true, "4c:20:b8:ab:87:1b", true},
|
||||||
|
{"no ip", "1.2.3.4", false, "4c:20:b8:ab:87:1b", false},
|
||||||
|
{"no mac", "1.2.3.4", false, "4c:20:b8:ab:87:1b", false},
|
||||||
|
}
|
||||||
|
for _, tc := range tests {
|
||||||
|
tc := tc
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
ip := net.ParseIP(tc.ip)
|
||||||
|
if ip == nil {
|
||||||
|
t.Fatal("missing IP")
|
||||||
|
}
|
||||||
|
hw, err := net.ParseMAC(tc.mac)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
m := new(dns.Msg)
|
||||||
|
m.SetQuestion("example.com.", dns.TypeA)
|
||||||
|
o := &dns.OPT{Hdr: dns.RR_Header{Name: ".", Rrtype: dns.TypeOPT}}
|
||||||
|
if tc.wantMac {
|
||||||
|
ec1 := &dns.EDNS0_LOCAL{Code: EDNS0_OPTION_MAC, Data: hw}
|
||||||
|
o.Option = append(o.Option, ec1)
|
||||||
|
}
|
||||||
|
if tc.wantIp {
|
||||||
|
ec2 := &dns.EDNS0_SUBNET{Address: ip}
|
||||||
|
o.Option = append(o.Option, ec2)
|
||||||
|
}
|
||||||
|
m.Extra = append(m.Extra, o)
|
||||||
|
gotIP, gotMac := ipAndMacFromMsg(m)
|
||||||
|
if tc.wantMac && gotMac != tc.mac {
|
||||||
|
t.Errorf("mismatch, want: %q, got: %q", tc.mac, gotMac)
|
||||||
|
}
|
||||||
|
if !tc.wantMac && gotMac != "" {
|
||||||
|
t.Errorf("unexpected mac: %q", gotMac)
|
||||||
|
}
|
||||||
|
if tc.wantIp && gotIP != tc.ip {
|
||||||
|
t.Errorf("mismatch, want: %q, got: %q", tc.ip, gotIP)
|
||||||
|
}
|
||||||
|
if !tc.wantIp && gotIP != "" {
|
||||||
|
t.Errorf("unexpected ip: %q", gotIP)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func Test_remoteAddrFromMsg(t *testing.T) {
|
||||||
|
loopbackIP := net.ParseIP("127.0.0.1")
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
addr net.Addr
|
||||||
|
ci *ctrld.ClientInfo
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"tcp", &net.TCPAddr{IP: loopbackIP, Port: 12345}, &ctrld.ClientInfo{IP: "192.168.1.10"}, "192.168.1.10:12345"},
|
||||||
|
{"udp", &net.UDPAddr{IP: loopbackIP, Port: 12345}, &ctrld.ClientInfo{IP: "192.168.1.11"}, "192.168.1.11:12345"},
|
||||||
|
{"nil client info", &net.UDPAddr{IP: loopbackIP, Port: 12345}, nil, "127.0.0.1:12345"},
|
||||||
|
{"empty ip", &net.UDPAddr{IP: loopbackIP, Port: 12345}, &ctrld.ClientInfo{}, "127.0.0.1:12345"},
|
||||||
|
}
|
||||||
|
for _, tc := range tests {
|
||||||
|
tc := tc
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
addr := spoofRemoteAddr(tc.addr, tc.ci)
|
||||||
|
if addr.String() != tc.want {
|
||||||
|
t.Errorf("unexpected result, want: %q, got: %q", tc.want, addr.String())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func Test_ipFromARPA(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
IP string
|
||||||
|
ARPA string
|
||||||
|
}{
|
||||||
|
{"1.2.3.4", "4.3.2.1.in-addr.arpa."},
|
||||||
|
{"245.110.36.114", "114.36.110.245.in-addr.arpa."},
|
||||||
|
{"::ffff:12.34.56.78", "78.56.34.12.in-addr.arpa."},
|
||||||
|
{"::1", "1.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.ip6.arpa."},
|
||||||
|
{"1::", "0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.1.0.0.0.ip6.arpa."},
|
||||||
|
{"1234:567::89a:bcde", "e.d.c.b.a.9.8.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.7.6.5.0.4.3.2.1.ip6.arpa."},
|
||||||
|
{"1234:567:fefe:bcbc:adad:9e4a:89a:bcde", "e.d.c.b.a.9.8.0.a.4.e.9.d.a.d.a.c.b.c.b.e.f.e.f.7.6.5.0.4.3.2.1.ip6.arpa."},
|
||||||
|
{"", "asd.in-addr.arpa."},
|
||||||
|
{"", "asd.ip6.arpa."},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
tc := tc
|
||||||
|
t.Run(tc.IP, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
if got := ipFromARPA(tc.ARPA); !got.Equal(net.ParseIP(tc.IP)) {
|
||||||
|
t.Errorf("unexpected ip, want: %s, got: %s", tc.IP, got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newDnsMsgWithClientIP(ip string) *dns.Msg {
|
||||||
|
m := new(dns.Msg)
|
||||||
|
m.SetQuestion("example.com.", dns.TypeA)
|
||||||
|
o := &dns.OPT{Hdr: dns.RR_Header{Name: ".", Rrtype: dns.TypeOPT}}
|
||||||
|
o.Option = append(o.Option, &dns.EDNS0_SUBNET{Address: net.ParseIP(ip)})
|
||||||
|
m.Extra = append(m.Extra, o)
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
func Test_stripClientSubnet(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
msg *dns.Msg
|
||||||
|
wantSubnet bool
|
||||||
|
}{
|
||||||
|
{"no edns0", new(dns.Msg), false},
|
||||||
|
{"loopback IP v4", newDnsMsgWithClientIP("127.0.0.1"), false},
|
||||||
|
{"loopback IP v6", newDnsMsgWithClientIP("::1"), false},
|
||||||
|
{"private IP v4", newDnsMsgWithClientIP("192.168.1.123"), false},
|
||||||
|
{"private IP v6", newDnsMsgWithClientIP("fd12:3456:789a:1::1"), false},
|
||||||
|
{"public IP", newDnsMsgWithClientIP("1.1.1.1"), true},
|
||||||
|
{"invalid IP", newDnsMsgWithClientIP(""), true},
|
||||||
|
}
|
||||||
|
for _, tc := range tests {
|
||||||
|
tc := tc
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
stripClientSubnet(tc.msg)
|
||||||
|
hasSubnet := false
|
||||||
|
if opt := tc.msg.IsEdns0(); opt != nil {
|
||||||
|
for _, s := range opt.Option {
|
||||||
|
if _, ok := s.(*dns.EDNS0_SUBNET); ok {
|
||||||
|
hasSubnet = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if tc.wantSubnet != hasSubnet {
|
||||||
|
t.Errorf("unexpected result, want: %v, got: %v", tc.wantSubnet, hasSubnet)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newDnsMsgWithHostname(hostname string, typ uint16) *dns.Msg {
|
||||||
|
m := new(dns.Msg)
|
||||||
|
m.SetQuestion(hostname, typ)
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
func Test_isLanHostnameQuery(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
msg *dns.Msg
|
||||||
|
isLanHostnameQuery bool
|
||||||
|
}{
|
||||||
|
{"A", newDnsMsgWithHostname("foo", dns.TypeA), true},
|
||||||
|
{"AAAA", newDnsMsgWithHostname("foo", dns.TypeAAAA), true},
|
||||||
|
{"A not LAN", newDnsMsgWithHostname("example.com", dns.TypeA), false},
|
||||||
|
{"AAAA not LAN", newDnsMsgWithHostname("example.com", dns.TypeAAAA), false},
|
||||||
|
{"Not A or AAAA", newDnsMsgWithHostname("foo", dns.TypeTXT), false},
|
||||||
|
{".domain", newDnsMsgWithHostname("foo.domain", dns.TypeA), true},
|
||||||
|
{".lan", newDnsMsgWithHostname("foo.lan", dns.TypeA), true},
|
||||||
|
{".local", newDnsMsgWithHostname("foo.local", dns.TypeA), true},
|
||||||
|
}
|
||||||
|
for _, tc := range tests {
|
||||||
|
tc := tc
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
if got := isLanHostnameQuery(tc.msg); tc.isLanHostnameQuery != got {
|
||||||
|
t.Errorf("unexpected result, want: %v, got: %v", tc.isLanHostnameQuery, got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newDnsMsgPtr(ip string, t *testing.T) *dns.Msg {
|
||||||
|
t.Helper()
|
||||||
|
m := new(dns.Msg)
|
||||||
|
ptr, err := dns.ReverseAddr(ip)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
m.SetQuestion(ptr, dns.TypePTR)
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
func Test_isPrivatePtrLookup(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
msg *dns.Msg
|
||||||
|
isPrivatePtrLookup bool
|
||||||
|
}{
|
||||||
|
// RFC 1918 allocates 10.0.0.0/8, 172.16.0.0/12, and 192.168.0.0/16 as
|
||||||
|
{"10.0.0.0/8", newDnsMsgPtr("10.0.0.123", t), true},
|
||||||
|
{"172.16.0.0/12", newDnsMsgPtr("172.16.0.123", t), true},
|
||||||
|
{"192.168.0.0/16", newDnsMsgPtr("192.168.1.123", t), true},
|
||||||
|
{"CGNAT", newDnsMsgPtr("100.66.27.28", t), true},
|
||||||
|
{"Loopback", newDnsMsgPtr("127.0.0.1", t), true},
|
||||||
|
{"Link Local Unicast", newDnsMsgPtr("fe80::69f6:e16e:8bdb:433f", t), true},
|
||||||
|
{"Public IP", newDnsMsgPtr("8.8.8.8", t), false},
|
||||||
|
}
|
||||||
|
for _, tc := range tests {
|
||||||
|
tc := tc
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
if got := isPrivatePtrLookup(tc.msg); tc.isPrivatePtrLookup != got {
|
||||||
|
t.Errorf("unexpected result, want: %v, got: %v", tc.isPrivatePtrLookup, got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func Test_isSrvLanLookup(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
msg *dns.Msg
|
||||||
|
isSrvLookup bool
|
||||||
|
}{
|
||||||
|
{"SRV LAN", newDnsMsgWithHostname("foo", dns.TypeSRV), true},
|
||||||
|
{"Not SRV", newDnsMsgWithHostname("foo", dns.TypeNone), false},
|
||||||
|
{"Not SRV LAN", newDnsMsgWithHostname("controld.com", dns.TypeSRV), false},
|
||||||
|
}
|
||||||
|
for _, tc := range tests {
|
||||||
|
tc := tc
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
if got := isSrvLanLookup(tc.msg); tc.isSrvLookup != got {
|
||||||
|
t.Errorf("unexpected result, want: %v, got: %v", tc.isSrvLookup, got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func Test_isWanClient(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
addr net.Addr
|
||||||
|
isWanClient bool
|
||||||
|
}{
|
||||||
|
// RFC 1918 allocates 10.0.0.0/8, 172.16.0.0/12, and 192.168.0.0/16 as
|
||||||
|
{"10.0.0.0/8", &net.UDPAddr{IP: net.ParseIP("10.0.0.123")}, false},
|
||||||
|
{"172.16.0.0/12", &net.UDPAddr{IP: net.ParseIP("172.16.0.123")}, false},
|
||||||
|
{"192.168.0.0/16", &net.UDPAddr{IP: net.ParseIP("192.168.1.123")}, false},
|
||||||
|
{"CGNAT", &net.UDPAddr{IP: net.ParseIP("100.66.27.28")}, false},
|
||||||
|
{"Loopback", &net.UDPAddr{IP: net.ParseIP("127.0.0.1")}, false},
|
||||||
|
{"Link Local Unicast", &net.UDPAddr{IP: net.ParseIP("fe80::69f6:e16e:8bdb:433f")}, false},
|
||||||
|
{"Public", &net.UDPAddr{IP: net.ParseIP("8.8.8.8")}, true},
|
||||||
|
}
|
||||||
|
for _, tc := range tests {
|
||||||
|
tc := tc
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
if got := isWanClient(tc.addr); tc.isWanClient != got {
|
||||||
|
t.Errorf("unexpected result, want: %v, got: %v", tc.isWanClient, got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
14
cmd/cli/hostname.go
Normal file
14
cmd/cli/hostname.go
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
package cli
|
||||||
|
|
||||||
|
import "regexp"
|
||||||
|
|
||||||
|
// validHostname reports whether hostname is a valid hostname.
|
||||||
|
// A valid hostname contains 3 -> 64 characters and conform to RFC1123.
|
||||||
|
func validHostname(hostname string) bool {
|
||||||
|
hostnameLen := len(hostname)
|
||||||
|
if hostnameLen < 3 || hostnameLen > 64 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
validHostnameRfc1123 := regexp.MustCompile(`^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]*[a-zA-Z0-9])\.)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9\-]*[A-Za-z0-9])$`)
|
||||||
|
return validHostnameRfc1123.MatchString(hostname)
|
||||||
|
}
|
||||||
35
cmd/cli/hostname_test.go
Normal file
35
cmd/cli/hostname_test.go
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Test_validHostname(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
hostname string
|
||||||
|
valid bool
|
||||||
|
}{
|
||||||
|
{"localhost", "localhost", true},
|
||||||
|
{"localdomain", "localhost.localdomain", true},
|
||||||
|
{"localhost6", "localhost6.localdomain6", true},
|
||||||
|
{"ip6", "ip6-localhost", true},
|
||||||
|
{"non-domain", "controld", true},
|
||||||
|
{"domain", "controld.com", true},
|
||||||
|
{"empty", "", false},
|
||||||
|
{"min length", "fo", false},
|
||||||
|
{"max length", strings.Repeat("a", 65), false},
|
||||||
|
{"special char", "foo!", false},
|
||||||
|
{"non-ascii", "fooΩ", false},
|
||||||
|
}
|
||||||
|
for _, tc := range tests {
|
||||||
|
tc := tc
|
||||||
|
t.Run(tc.hostname, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
assert.True(t, validHostname(tc.hostname) == tc.valid)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
93
cmd/cli/library.go
Normal file
93
cmd/cli/library.go
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AppCallback provides hooks for injecting certain functionalities
|
||||||
|
// from mobile platforms to main ctrld cli.
|
||||||
|
type AppCallback struct {
|
||||||
|
HostName func() string
|
||||||
|
LanIp func() string
|
||||||
|
MacAddress func() string
|
||||||
|
Exit func(error string)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AppConfig allows overwriting ctrld cli flags from mobile platforms.
|
||||||
|
type AppConfig struct {
|
||||||
|
CdUID string
|
||||||
|
HomeDir string
|
||||||
|
UpstreamProto string
|
||||||
|
Verbose int
|
||||||
|
LogPath string
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
defaultHTTPTimeout = 30 * time.Second
|
||||||
|
defaultMaxRetries = 3
|
||||||
|
downloadServerIp = "23.171.240.151"
|
||||||
|
)
|
||||||
|
|
||||||
|
// httpClientWithFallback returns an HTTP client configured with timeout and IPv4 fallback
|
||||||
|
func httpClientWithFallback(timeout time.Duration) *http.Client {
|
||||||
|
return &http.Client{
|
||||||
|
Timeout: timeout,
|
||||||
|
Transport: &http.Transport{
|
||||||
|
// Prefer IPv4 over IPv6
|
||||||
|
DialContext: (&net.Dialer{
|
||||||
|
Timeout: 10 * time.Second,
|
||||||
|
KeepAlive: 30 * time.Second,
|
||||||
|
FallbackDelay: 1 * time.Millisecond, // Very small delay to prefer IPv4
|
||||||
|
}).DialContext,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// doWithRetry performs an HTTP request with retries
|
||||||
|
func doWithRetry(req *http.Request, maxRetries int, ip string) (*http.Response, error) {
|
||||||
|
var lastErr error
|
||||||
|
client := httpClientWithFallback(defaultHTTPTimeout)
|
||||||
|
var ipReq *http.Request
|
||||||
|
if ip != "" {
|
||||||
|
ipReq = req.Clone(req.Context())
|
||||||
|
ipReq.Host = ip
|
||||||
|
ipReq.URL.Host = ip
|
||||||
|
}
|
||||||
|
for attempt := 0; attempt < maxRetries; attempt++ {
|
||||||
|
if attempt > 0 {
|
||||||
|
time.Sleep(time.Second * time.Duration(attempt+1)) // Exponential backoff
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err == nil {
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
if ipReq != nil {
|
||||||
|
mainLog.Load().Warn().Err(err).Msgf("dial to %q failed", req.Host)
|
||||||
|
mainLog.Load().Warn().Msgf("fallback to direct IP to download prod version: %q", ip)
|
||||||
|
resp, err = client.Do(ipReq)
|
||||||
|
if err == nil {
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
lastErr = err
|
||||||
|
mainLog.Load().Debug().Err(err).
|
||||||
|
Str("method", req.Method).
|
||||||
|
Str("url", req.URL.String()).
|
||||||
|
Msgf("HTTP request attempt %d/%d failed", attempt+1, maxRetries)
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("failed after %d attempts to %s %s: %v", maxRetries, req.Method, req.URL, lastErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper for making GET requests with retries
|
||||||
|
func getWithRetry(url string, ip string) (*http.Response, error) {
|
||||||
|
req, err := http.NewRequest(http.MethodGet, url, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return doWithRetry(req, defaultMaxRetries, ip)
|
||||||
|
}
|
||||||
204
cmd/cli/log_writer.go
Normal file
204
cmd/cli/log_writer.go
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/rs/zerolog"
|
||||||
|
|
||||||
|
"github.com/Control-D-Inc/ctrld"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
logWriterSize = 1024 * 1024 * 5 // 5 MB
|
||||||
|
logWriterSmallSize = 1024 * 1024 * 1 // 1 MB
|
||||||
|
logWriterInitialSize = 32 * 1024 // 32 KB
|
||||||
|
logWriterSentInterval = time.Minute
|
||||||
|
logWriterInitEndMarker = "\n\n=== INIT_END ===\n\n"
|
||||||
|
logWriterLogEndMarker = "\n\n=== LOG_END ===\n\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
type logViewResponse struct {
|
||||||
|
Data string `json:"data"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type logSentResponse struct {
|
||||||
|
Size int64 `json:"size"`
|
||||||
|
Error string `json:"error"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type logReader struct {
|
||||||
|
r io.ReadCloser
|
||||||
|
size int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// logWriter is an internal buffer to keep track of runtime log when no logging is enabled.
|
||||||
|
type logWriter struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
buf bytes.Buffer
|
||||||
|
size int
|
||||||
|
}
|
||||||
|
|
||||||
|
// newLogWriter creates an internal log writer.
|
||||||
|
func newLogWriter() *logWriter {
|
||||||
|
return newLogWriterWithSize(logWriterSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
// newSmallLogWriter creates an internal log writer with small buffer size.
|
||||||
|
func newSmallLogWriter() *logWriter {
|
||||||
|
return newLogWriterWithSize(logWriterSmallSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
// newLogWriterWithSize creates an internal log writer with a given buffer size.
|
||||||
|
func newLogWriterWithSize(size int) *logWriter {
|
||||||
|
lw := &logWriter{size: size}
|
||||||
|
return lw
|
||||||
|
}
|
||||||
|
|
||||||
|
func (lw *logWriter) Write(p []byte) (int, error) {
|
||||||
|
lw.mu.Lock()
|
||||||
|
defer lw.mu.Unlock()
|
||||||
|
|
||||||
|
// If writing p causes overflows, discard old data.
|
||||||
|
if lw.buf.Len()+len(p) > lw.size {
|
||||||
|
buf := lw.buf.Bytes()
|
||||||
|
haveEndMarker := false
|
||||||
|
// If there's init end marker already, preserve the data til the marker.
|
||||||
|
if idx := bytes.LastIndex(buf, []byte(logWriterInitEndMarker)); idx >= 0 {
|
||||||
|
buf = buf[:idx+len(logWriterInitEndMarker)]
|
||||||
|
haveEndMarker = true
|
||||||
|
} else {
|
||||||
|
// Otherwise, preserve the initial size data.
|
||||||
|
buf = buf[:logWriterInitialSize]
|
||||||
|
if idx := bytes.LastIndex(buf, []byte("\n")); idx != -1 {
|
||||||
|
buf = buf[:idx]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lw.buf.Reset()
|
||||||
|
lw.buf.Write(buf)
|
||||||
|
if !haveEndMarker {
|
||||||
|
lw.buf.WriteString(logWriterInitEndMarker) // indicate that the log was truncated.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// If p is bigger than buffer size, truncate p by half until its size is smaller.
|
||||||
|
for len(p)+lw.buf.Len() > lw.size {
|
||||||
|
p = p[len(p)/2:]
|
||||||
|
}
|
||||||
|
return lw.buf.Write(p)
|
||||||
|
}
|
||||||
|
|
||||||
|
// initLogging initializes global logging setup.
|
||||||
|
func (p *prog) initLogging(backup bool) {
|
||||||
|
zerolog.TimeFieldFormat = time.RFC3339 + ".000"
|
||||||
|
logWriters := initLoggingWithBackup(backup)
|
||||||
|
|
||||||
|
// Initializing internal logging after global logging.
|
||||||
|
p.initInternalLogging(logWriters)
|
||||||
|
}
|
||||||
|
|
||||||
|
// initInternalLogging performs internal logging if there's no log enabled.
|
||||||
|
func (p *prog) initInternalLogging(writers []io.Writer) {
|
||||||
|
if !p.needInternalLogging() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
p.initInternalLogWriterOnce.Do(func() {
|
||||||
|
mainLog.Load().Notice().Msg("internal logging enabled")
|
||||||
|
p.internalLogWriter = newLogWriter()
|
||||||
|
p.internalLogSent = time.Now().Add(-logWriterSentInterval)
|
||||||
|
p.internalWarnLogWriter = newSmallLogWriter()
|
||||||
|
})
|
||||||
|
p.mu.Lock()
|
||||||
|
lw := p.internalLogWriter
|
||||||
|
wlw := p.internalWarnLogWriter
|
||||||
|
p.mu.Unlock()
|
||||||
|
// If ctrld was run without explicit verbose level,
|
||||||
|
// run the internal logging at debug level, so we could
|
||||||
|
// have enough information for troubleshooting.
|
||||||
|
if verbose == 0 {
|
||||||
|
for i := range writers {
|
||||||
|
w := &zerolog.FilteredLevelWriter{
|
||||||
|
Writer: zerolog.LevelWriterAdapter{Writer: writers[i]},
|
||||||
|
Level: zerolog.NoticeLevel,
|
||||||
|
}
|
||||||
|
writers[i] = w
|
||||||
|
}
|
||||||
|
zerolog.SetGlobalLevel(zerolog.DebugLevel)
|
||||||
|
}
|
||||||
|
writers = append(writers, lw)
|
||||||
|
writers = append(writers, &zerolog.FilteredLevelWriter{
|
||||||
|
Writer: zerolog.LevelWriterAdapter{Writer: wlw},
|
||||||
|
Level: zerolog.WarnLevel,
|
||||||
|
})
|
||||||
|
multi := zerolog.MultiLevelWriter(writers...)
|
||||||
|
l := mainLog.Load().Output(multi).With().Logger()
|
||||||
|
mainLog.Store(&l)
|
||||||
|
ctrld.ProxyLogger.Store(&l)
|
||||||
|
}
|
||||||
|
|
||||||
|
// needInternalLogging reports whether prog needs to run internal logging.
|
||||||
|
func (p *prog) needInternalLogging() bool {
|
||||||
|
// Do not run in non-cd mode.
|
||||||
|
if cdUID == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
// Do not run if there's already log file.
|
||||||
|
if p.cfg.Service.LogPath != "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *prog) logReader() (*logReader, error) {
|
||||||
|
if p.needInternalLogging() {
|
||||||
|
p.mu.Lock()
|
||||||
|
lw := p.internalLogWriter
|
||||||
|
wlw := p.internalWarnLogWriter
|
||||||
|
p.mu.Unlock()
|
||||||
|
if lw == nil {
|
||||||
|
return nil, errors.New("nil internal log writer")
|
||||||
|
}
|
||||||
|
if wlw == nil {
|
||||||
|
return nil, errors.New("nil internal warn log writer")
|
||||||
|
}
|
||||||
|
// Normal log content.
|
||||||
|
lw.mu.Lock()
|
||||||
|
lwReader := bytes.NewReader(lw.buf.Bytes())
|
||||||
|
lwSize := lw.buf.Len()
|
||||||
|
lw.mu.Unlock()
|
||||||
|
// Warn log content.
|
||||||
|
wlw.mu.Lock()
|
||||||
|
wlwReader := bytes.NewReader(wlw.buf.Bytes())
|
||||||
|
wlwSize := wlw.buf.Len()
|
||||||
|
wlw.mu.Unlock()
|
||||||
|
reader := io.MultiReader(lwReader, bytes.NewReader([]byte(logWriterLogEndMarker)), wlwReader)
|
||||||
|
lr := &logReader{r: io.NopCloser(reader)}
|
||||||
|
lr.size = int64(lwSize + wlwSize)
|
||||||
|
if lr.size == 0 {
|
||||||
|
return nil, errors.New("internal log is empty")
|
||||||
|
}
|
||||||
|
return lr, nil
|
||||||
|
}
|
||||||
|
if p.cfg.Service.LogPath == "" {
|
||||||
|
return &logReader{r: io.NopCloser(strings.NewReader(""))}, nil
|
||||||
|
}
|
||||||
|
f, err := os.Open(normalizeLogFilePath(p.cfg.Service.LogPath))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
lr := &logReader{r: f}
|
||||||
|
if st, err := f.Stat(); err == nil {
|
||||||
|
lr.size = st.Size()
|
||||||
|
} else {
|
||||||
|
return nil, fmt.Errorf("f.Stat: %w", err)
|
||||||
|
}
|
||||||
|
if lr.size == 0 {
|
||||||
|
return nil, errors.New("log file is empty")
|
||||||
|
}
|
||||||
|
return lr, nil
|
||||||
|
}
|
||||||
85
cmd/cli/log_writer_test.go
Normal file
85
cmd/cli/log_writer_test.go
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Test_logWriter_Write(t *testing.T) {
|
||||||
|
size := 64 * 1024
|
||||||
|
lw := &logWriter{size: size}
|
||||||
|
lw.buf.Grow(lw.size)
|
||||||
|
data := strings.Repeat("A", size)
|
||||||
|
lw.Write([]byte(data))
|
||||||
|
if lw.buf.String() != data {
|
||||||
|
t.Fatalf("unexpected buf content: %v", lw.buf.String())
|
||||||
|
}
|
||||||
|
newData := "B"
|
||||||
|
halfData := strings.Repeat("A", len(data)/2) + logWriterInitEndMarker
|
||||||
|
lw.Write([]byte(newData))
|
||||||
|
if lw.buf.String() != halfData+newData {
|
||||||
|
t.Fatalf("unexpected new buf content: %v", lw.buf.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
bigData := strings.Repeat("B", 256*1024)
|
||||||
|
expected := halfData + strings.Repeat("B", 16*1024)
|
||||||
|
lw.Write([]byte(bigData))
|
||||||
|
if lw.buf.String() != expected {
|
||||||
|
t.Fatalf("unexpected big buf content: %v", lw.buf.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func Test_logWriter_ConcurrentWrite(t *testing.T) {
|
||||||
|
size := 64 * 1024
|
||||||
|
lw := &logWriter{size: size}
|
||||||
|
n := 10
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
wg.Add(n)
|
||||||
|
for i := 0; i < n; i++ {
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
lw.Write([]byte(strings.Repeat("A", i)))
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
if lw.buf.Len() > lw.size {
|
||||||
|
t.Fatalf("unexpected buf size: %v, content: %q", lw.buf.Len(), lw.buf.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func Test_logWriter_MarkerInitEnd(t *testing.T) {
|
||||||
|
size := 64 * 1024
|
||||||
|
lw := &logWriter{size: size}
|
||||||
|
lw.buf.Grow(lw.size)
|
||||||
|
|
||||||
|
paddingSize := 10
|
||||||
|
// Writing half of the size, minus len(end marker) and padding size.
|
||||||
|
dataSize := size/2 - len(logWriterInitEndMarker) - paddingSize
|
||||||
|
data := strings.Repeat("A", dataSize)
|
||||||
|
// Inserting newline for making partial init data
|
||||||
|
data += "\n"
|
||||||
|
// Filling left over buffer to make the log full.
|
||||||
|
// The data length: len(end marker) + padding size - 1 (for newline above) + size/2
|
||||||
|
data += strings.Repeat("A", len(logWriterInitEndMarker)+paddingSize-1+(size/2))
|
||||||
|
lw.Write([]byte(data))
|
||||||
|
if lw.buf.String() != data {
|
||||||
|
t.Fatalf("unexpected buf content: %v", lw.buf.String())
|
||||||
|
}
|
||||||
|
lw.Write([]byte("B"))
|
||||||
|
lw.Write([]byte(strings.Repeat("B", 256*1024)))
|
||||||
|
firstIdx := strings.Index(lw.buf.String(), logWriterInitEndMarker)
|
||||||
|
lastIdx := strings.LastIndex(lw.buf.String(), logWriterInitEndMarker)
|
||||||
|
// Check if init end marker present.
|
||||||
|
if firstIdx == -1 || lastIdx == -1 {
|
||||||
|
t.Fatalf("missing init end marker: %s", lw.buf.String())
|
||||||
|
}
|
||||||
|
// Check if init end marker appears only once.
|
||||||
|
if firstIdx != lastIdx {
|
||||||
|
t.Fatalf("log init end marker appears more than once: %s", lw.buf.String())
|
||||||
|
}
|
||||||
|
// Ensure that we have the correct init log data.
|
||||||
|
if !strings.Contains(lw.buf.String(), strings.Repeat("A", dataSize)+logWriterInitEndMarker) {
|
||||||
|
t.Fatalf("unexpected log content: %s", lw.buf.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
145
cmd/cli/loop.go
Normal file
145
cmd/cli/loop.go
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/miekg/dns"
|
||||||
|
|
||||||
|
"github.com/Control-D-Inc/ctrld"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
loopTestDomain = ".test"
|
||||||
|
loopTestQtype = dns.TypeTXT
|
||||||
|
)
|
||||||
|
|
||||||
|
// newLoopGuard returns new loopGuard.
|
||||||
|
func newLoopGuard() *loopGuard {
|
||||||
|
return &loopGuard{inflight: make(map[string]struct{})}
|
||||||
|
}
|
||||||
|
|
||||||
|
// loopGuard guards against DNS loop, ensuring only one query
|
||||||
|
// for a given domain is processed at a time.
|
||||||
|
type loopGuard struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
inflight map[string]struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TryLock marks the domain as being processed.
|
||||||
|
func (lg *loopGuard) TryLock(domain string) bool {
|
||||||
|
lg.mu.Lock()
|
||||||
|
defer lg.mu.Unlock()
|
||||||
|
if _, inflight := lg.inflight[domain]; !inflight {
|
||||||
|
lg.inflight[domain] = struct{}{}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unlock marks the domain as being done.
|
||||||
|
func (lg *loopGuard) Unlock(domain string) {
|
||||||
|
lg.mu.Lock()
|
||||||
|
defer lg.mu.Unlock()
|
||||||
|
delete(lg.inflight, domain)
|
||||||
|
}
|
||||||
|
|
||||||
|
// isLoop reports whether the given upstream config is detected as having DNS loop.
|
||||||
|
func (p *prog) isLoop(uc *ctrld.UpstreamConfig) bool {
|
||||||
|
p.loopMu.Lock()
|
||||||
|
defer p.loopMu.Unlock()
|
||||||
|
return p.loop[uc.UID()]
|
||||||
|
}
|
||||||
|
|
||||||
|
// detectLoop checks if the given DNS message is initialized sent by ctrld.
|
||||||
|
// If yes, marking the corresponding upstream as loop, prevent infinite DNS
|
||||||
|
// forwarding loop.
|
||||||
|
//
|
||||||
|
// See p.checkDnsLoop for more details how it works.
|
||||||
|
func (p *prog) detectLoop(msg *dns.Msg) {
|
||||||
|
if len(msg.Question) != 1 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
q := msg.Question[0]
|
||||||
|
if q.Qtype != loopTestQtype {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
unFQDNname := strings.TrimSuffix(q.Name, ".")
|
||||||
|
uid := strings.TrimSuffix(unFQDNname, loopTestDomain)
|
||||||
|
p.loopMu.Lock()
|
||||||
|
if _, loop := p.loop[uid]; loop {
|
||||||
|
p.loop[uid] = loop
|
||||||
|
}
|
||||||
|
p.loopMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkDnsLoop sends a message to check if there's any DNS forwarding loop
|
||||||
|
// with all the upstreams. The way it works based on dnsmasq --dns-loop-detect.
|
||||||
|
//
|
||||||
|
// - Generating a TXT test query and sending it to all upstream.
|
||||||
|
// - The test query is formed by upstream UID and test domain: <uid>.test
|
||||||
|
// - If the test query returns to ctrld, mark the corresponding upstream as loop (see p.detectLoop).
|
||||||
|
//
|
||||||
|
// See: https://thekelleys.org.uk/dnsmasq/docs/dnsmasq-man.html
|
||||||
|
func (p *prog) checkDnsLoop() {
|
||||||
|
mainLog.Load().Debug().Msg("start checking DNS loop")
|
||||||
|
upstream := make(map[string]*ctrld.UpstreamConfig)
|
||||||
|
p.loopMu.Lock()
|
||||||
|
for n, uc := range p.cfg.Upstream {
|
||||||
|
if p.um.isDown("upstream." + n) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Do not send test query to external upstream.
|
||||||
|
if !canBeLocalUpstream(uc.Domain) {
|
||||||
|
mainLog.Load().Debug().Msgf("skipping external: upstream.%s", n)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
uid := uc.UID()
|
||||||
|
p.loop[uid] = false
|
||||||
|
upstream[uid] = uc
|
||||||
|
}
|
||||||
|
p.loopMu.Unlock()
|
||||||
|
|
||||||
|
for uid := range p.loop {
|
||||||
|
msg := loopTestMsg(uid)
|
||||||
|
uc := upstream[uid]
|
||||||
|
// Skipping upstream which is being marked as down.
|
||||||
|
if uc == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
resolver, err := ctrld.NewResolver(uc)
|
||||||
|
if err != nil {
|
||||||
|
mainLog.Load().Warn().Err(err).Msgf("could not perform loop check for upstream: %q, endpoint: %q", uc.Name, uc.Endpoint)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, err := resolver.Resolve(context.Background(), msg); err != nil {
|
||||||
|
mainLog.Load().Warn().Err(err).Msgf("could not send DNS loop check query for upstream: %q, endpoint: %q", uc.Name, uc.Endpoint)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
mainLog.Load().Debug().Msg("end checking DNS loop")
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkDnsLoopTicker performs p.checkDnsLoop every minute.
|
||||||
|
func (p *prog) checkDnsLoopTicker(ctx context.Context) {
|
||||||
|
timer := time.NewTicker(time.Minute)
|
||||||
|
defer timer.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-p.stopCh:
|
||||||
|
return
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-timer.C:
|
||||||
|
p.checkDnsLoop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// loopTestMsg generates DNS message for checking loop.
|
||||||
|
func loopTestMsg(uid string) *dns.Msg {
|
||||||
|
msg := new(dns.Msg)
|
||||||
|
msg.SetQuestion(dns.Fqdn(uid+loopTestDomain), loopTestQtype)
|
||||||
|
return msg
|
||||||
|
}
|
||||||
42
cmd/cli/loop_test.go
Normal file
42
cmd/cli/loop_test.go
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Test_loopGuard(t *testing.T) {
|
||||||
|
lg := newLoopGuard()
|
||||||
|
key := "foo"
|
||||||
|
|
||||||
|
var i atomic.Int64
|
||||||
|
var started atomic.Int64
|
||||||
|
n := 1000
|
||||||
|
do := func() {
|
||||||
|
locked := lg.TryLock(key)
|
||||||
|
defer lg.Unlock(key)
|
||||||
|
started.Add(1)
|
||||||
|
for started.Load() < 2 {
|
||||||
|
// Wait until at least 2 goroutines started, otherwise, on system with heavy load,
|
||||||
|
// or having only 1 CPU, all goroutines can be scheduled to run consequently.
|
||||||
|
}
|
||||||
|
if locked {
|
||||||
|
i.Add(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
wg.Add(n)
|
||||||
|
for i := 0; i < n; i++ {
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
do()
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
if i.Load() == int64(n) {
|
||||||
|
t.Fatalf("i must not be increased %d times", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
190
cmd/cli/main.go
Normal file
190
cmd/cli/main.go
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/kardianos/service"
|
||||||
|
"github.com/rs/zerolog"
|
||||||
|
|
||||||
|
"github.com/Control-D-Inc/ctrld"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
configPath string
|
||||||
|
configBase64 string
|
||||||
|
daemon bool
|
||||||
|
listenAddress string
|
||||||
|
primaryUpstream string
|
||||||
|
secondaryUpstream string
|
||||||
|
domains []string
|
||||||
|
logPath string
|
||||||
|
homedir string
|
||||||
|
cacheSize int
|
||||||
|
cfg ctrld.Config
|
||||||
|
verbose int
|
||||||
|
silent bool
|
||||||
|
cdUID string
|
||||||
|
cdOrg string
|
||||||
|
customHostname string
|
||||||
|
cdDev bool
|
||||||
|
iface string
|
||||||
|
ifaceStartStop string
|
||||||
|
nextdns string
|
||||||
|
cdUpstreamProto string
|
||||||
|
deactivationPin int64
|
||||||
|
skipSelfChecks bool
|
||||||
|
cleanup bool
|
||||||
|
startOnly bool
|
||||||
|
|
||||||
|
mainLog atomic.Pointer[zerolog.Logger]
|
||||||
|
consoleWriter zerolog.ConsoleWriter
|
||||||
|
noConfigStart bool
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
cdUidFlagName = "cd"
|
||||||
|
cdOrgFlagName = "cd-org"
|
||||||
|
customHostnameFlagName = "custom-hostname"
|
||||||
|
nextdnsFlagName = "nextdns"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
l := zerolog.New(io.Discard)
|
||||||
|
mainLog.Store(&l)
|
||||||
|
}
|
||||||
|
|
||||||
|
func Main() {
|
||||||
|
ctrld.InitConfig(v, "ctrld")
|
||||||
|
initCLI()
|
||||||
|
if err := rootCmd.Execute(); err != nil {
|
||||||
|
mainLog.Load().Error().Msg(err.Error())
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeLogFilePath(logFilePath string) string {
|
||||||
|
if logFilePath == "" || filepath.IsAbs(logFilePath) || service.Interactive() {
|
||||||
|
return logFilePath
|
||||||
|
}
|
||||||
|
if homedir != "" {
|
||||||
|
return filepath.Join(homedir, logFilePath)
|
||||||
|
}
|
||||||
|
dir, _ := userHomeDir()
|
||||||
|
if dir == "" {
|
||||||
|
return logFilePath
|
||||||
|
}
|
||||||
|
return filepath.Join(dir, logFilePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// initConsoleLogging initializes console logging, then storing to mainLog.
|
||||||
|
func initConsoleLogging() {
|
||||||
|
consoleWriter = zerolog.NewConsoleWriter(func(w *zerolog.ConsoleWriter) {
|
||||||
|
w.TimeFormat = time.StampMilli
|
||||||
|
})
|
||||||
|
multi := zerolog.MultiLevelWriter(consoleWriter)
|
||||||
|
l := mainLog.Load().Output(multi).With().Timestamp().Logger()
|
||||||
|
mainLog.Store(&l)
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case silent:
|
||||||
|
zerolog.SetGlobalLevel(zerolog.NoLevel)
|
||||||
|
case verbose == 1:
|
||||||
|
ctrld.ProxyLogger.Store(&l)
|
||||||
|
zerolog.SetGlobalLevel(zerolog.InfoLevel)
|
||||||
|
case verbose > 1:
|
||||||
|
ctrld.ProxyLogger.Store(&l)
|
||||||
|
zerolog.SetGlobalLevel(zerolog.DebugLevel)
|
||||||
|
default:
|
||||||
|
zerolog.SetGlobalLevel(zerolog.NoticeLevel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// initInteractiveLogging is like initLogging, but the ProxyLogger is discarded
|
||||||
|
// to be used for all interactive commands.
|
||||||
|
//
|
||||||
|
// Current log file config will also be ignored.
|
||||||
|
func initInteractiveLogging() {
|
||||||
|
old := cfg.Service.LogPath
|
||||||
|
cfg.Service.LogPath = ""
|
||||||
|
zerolog.TimeFieldFormat = time.RFC3339 + ".000"
|
||||||
|
initLoggingWithBackup(false)
|
||||||
|
cfg.Service.LogPath = old
|
||||||
|
l := zerolog.New(io.Discard)
|
||||||
|
ctrld.ProxyLogger.Store(&l)
|
||||||
|
}
|
||||||
|
|
||||||
|
// initLoggingWithBackup initializes log setup base on current config.
|
||||||
|
// If doBackup is true, backup old log file with ".1" suffix.
|
||||||
|
//
|
||||||
|
// This is only used in runCmd for special handling in case of logging config
|
||||||
|
// change in cd mode. Without special reason, the caller should use initLogging
|
||||||
|
// wrapper instead of calling this function directly.
|
||||||
|
func initLoggingWithBackup(doBackup bool) []io.Writer {
|
||||||
|
var writers []io.Writer
|
||||||
|
if logFilePath := normalizeLogFilePath(cfg.Service.LogPath); logFilePath != "" {
|
||||||
|
// Create parent directory if necessary.
|
||||||
|
if err := os.MkdirAll(filepath.Dir(logFilePath), 0750); err != nil {
|
||||||
|
mainLog.Load().Error().Msgf("failed to create log path: %v", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default open log file in append mode.
|
||||||
|
flags := os.O_CREATE | os.O_RDWR | os.O_APPEND
|
||||||
|
if doBackup {
|
||||||
|
// Backup old log file with .1 suffix.
|
||||||
|
if err := os.Rename(logFilePath, logFilePath+oldLogSuffix); err != nil && !os.IsNotExist(err) {
|
||||||
|
mainLog.Load().Error().Msgf("could not backup old log file: %v", err)
|
||||||
|
} else {
|
||||||
|
// Backup was created, set flags for truncating old log file.
|
||||||
|
flags = os.O_CREATE | os.O_RDWR
|
||||||
|
}
|
||||||
|
}
|
||||||
|
logFile, err := openLogFile(logFilePath, flags)
|
||||||
|
if err != nil {
|
||||||
|
mainLog.Load().Error().Msgf("failed to create log file: %v", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
writers = append(writers, logFile)
|
||||||
|
}
|
||||||
|
writers = append(writers, consoleWriter)
|
||||||
|
multi := zerolog.MultiLevelWriter(writers...)
|
||||||
|
l := mainLog.Load().Output(multi).With().Logger()
|
||||||
|
mainLog.Store(&l)
|
||||||
|
// TODO: find a better way.
|
||||||
|
ctrld.ProxyLogger.Store(&l)
|
||||||
|
|
||||||
|
zerolog.SetGlobalLevel(zerolog.NoticeLevel)
|
||||||
|
logLevel := cfg.Service.LogLevel
|
||||||
|
switch {
|
||||||
|
case silent:
|
||||||
|
zerolog.SetGlobalLevel(zerolog.NoLevel)
|
||||||
|
return writers
|
||||||
|
case verbose == 1:
|
||||||
|
logLevel = "info"
|
||||||
|
case verbose > 1:
|
||||||
|
logLevel = "debug"
|
||||||
|
}
|
||||||
|
if logLevel == "" {
|
||||||
|
return writers
|
||||||
|
}
|
||||||
|
level, err := zerolog.ParseLevel(logLevel)
|
||||||
|
if err != nil {
|
||||||
|
mainLog.Load().Warn().Err(err).Msg("could not set log level")
|
||||||
|
return writers
|
||||||
|
}
|
||||||
|
zerolog.SetGlobalLevel(level)
|
||||||
|
return writers
|
||||||
|
}
|
||||||
|
|
||||||
|
func initCache() {
|
||||||
|
if !cfg.Service.CacheEnable {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if cfg.Service.CacheSize == 0 {
|
||||||
|
cfg.Service.CacheSize = 4096
|
||||||
|
}
|
||||||
|
}
|
||||||
17
cmd/cli/main_test.go
Normal file
17
cmd/cli/main_test.go
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/rs/zerolog"
|
||||||
|
)
|
||||||
|
|
||||||
|
var logOutput strings.Builder
|
||||||
|
|
||||||
|
func TestMain(m *testing.M) {
|
||||||
|
l := zerolog.New(&logOutput)
|
||||||
|
mainLog.Store(&l)
|
||||||
|
os.Exit(m.Run())
|
||||||
|
}
|
||||||
150
cmd/cli/metrics.go
Normal file
150
cmd/cli/metrics.go
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"runtime"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/prometheus/client_golang/prometheus"
|
||||||
|
"github.com/prometheus/client_golang/prometheus/collectors"
|
||||||
|
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||||
|
"github.com/prometheus/prom2json"
|
||||||
|
)
|
||||||
|
|
||||||
|
// metricsServer represents a server to expose Prometheus metrics via HTTP.
|
||||||
|
type metricsServer struct {
|
||||||
|
server *http.Server
|
||||||
|
mux *http.ServeMux
|
||||||
|
reg *prometheus.Registry
|
||||||
|
addr string
|
||||||
|
started bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// newMetricsServer returns new metrics server.
|
||||||
|
func newMetricsServer(addr string, reg *prometheus.Registry) (*metricsServer, error) {
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
ms := &metricsServer{
|
||||||
|
server: &http.Server{Handler: mux},
|
||||||
|
mux: mux,
|
||||||
|
reg: reg,
|
||||||
|
}
|
||||||
|
ms.addr = addr
|
||||||
|
ms.registerMetricsServerHandler()
|
||||||
|
return ms, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// register adds handlers for given pattern.
|
||||||
|
func (ms *metricsServer) register(pattern string, handler http.Handler) {
|
||||||
|
ms.mux.Handle(pattern, handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
// registerMetricsServerHandler adds handlers for metrics server.
|
||||||
|
func (ms *metricsServer) registerMetricsServerHandler() {
|
||||||
|
ms.register("/metrics", promhttp.HandlerFor(
|
||||||
|
ms.reg,
|
||||||
|
promhttp.HandlerOpts{
|
||||||
|
EnableOpenMetrics: true,
|
||||||
|
Timeout: 10 * time.Second,
|
||||||
|
},
|
||||||
|
))
|
||||||
|
ms.register("/metrics/json", jsonResponse(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
g := prometheus.ToTransactionalGatherer(ms.reg)
|
||||||
|
mfs, done, err := g.Gather()
|
||||||
|
defer done()
|
||||||
|
if err != nil {
|
||||||
|
msg := "could not gather metrics"
|
||||||
|
mainLog.Load().Warn().Err(err).Msg(msg)
|
||||||
|
http.Error(w, msg, http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
result := make([]*prom2json.Family, 0, len(mfs))
|
||||||
|
for _, mf := range mfs {
|
||||||
|
result = append(result, prom2json.NewFamily(mf))
|
||||||
|
}
|
||||||
|
if err := json.NewEncoder(w).Encode(result); err != nil {
|
||||||
|
msg := "could not marshal metrics result"
|
||||||
|
mainLog.Load().Warn().Err(err).Msg(msg)
|
||||||
|
http.Error(w, msg, http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
|
// start runs the metricsServer.
|
||||||
|
func (ms *metricsServer) start() error {
|
||||||
|
listener, err := net.Listen("tcp", ms.addr)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
go ms.server.Serve(listener)
|
||||||
|
ms.started = true
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// stop shutdowns the metricsServer within 2 seconds timeout.
|
||||||
|
func (ms *metricsServer) stop() error {
|
||||||
|
if !ms.started {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), time.Second*1)
|
||||||
|
defer cancel()
|
||||||
|
return ms.server.Shutdown(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// runMetricsServer initializes metrics stats and runs the metrics server if enabled.
|
||||||
|
func (p *prog) runMetricsServer(ctx context.Context, reloadCh chan struct{}) {
|
||||||
|
if !p.metricsEnabled() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset all stats.
|
||||||
|
statsVersion.Reset()
|
||||||
|
statsQueriesCount.Reset()
|
||||||
|
statsClientQueriesCount.Reset()
|
||||||
|
|
||||||
|
reg := prometheus.NewRegistry()
|
||||||
|
// Register queries count stats if enabled.
|
||||||
|
if p.metricsQueryStats.Load() {
|
||||||
|
reg.MustRegister(statsQueriesCount)
|
||||||
|
reg.MustRegister(statsClientQueriesCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
addr := p.cfg.Service.MetricsListener
|
||||||
|
ms, err := newMetricsServer(addr, reg)
|
||||||
|
if err != nil {
|
||||||
|
mainLog.Load().Warn().Err(err).Msg("could not create new metrics server")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Only start listener address if defined.
|
||||||
|
if addr != "" {
|
||||||
|
// Go runtime stats.
|
||||||
|
reg.MustRegister(collectors.NewBuildInfoCollector())
|
||||||
|
reg.MustRegister(collectors.NewGoCollector(
|
||||||
|
collectors.WithGoCollectorRuntimeMetrics(collectors.MetricsAll),
|
||||||
|
))
|
||||||
|
// ctrld stats.
|
||||||
|
reg.MustRegister(statsVersion)
|
||||||
|
statsVersion.WithLabelValues(commit, runtime.Version(), curVersion()).Inc()
|
||||||
|
reg.MustRegister(statsTimeStart)
|
||||||
|
statsTimeStart.Set(float64(time.Now().Unix()))
|
||||||
|
mainLog.Load().Debug().Msgf("starting metrics server on: %s", addr)
|
||||||
|
if err := ms.start(); err != nil {
|
||||||
|
mainLog.Load().Warn().Err(err).Msg("could not start metrics server")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-p.stopCh:
|
||||||
|
case <-ctx.Done():
|
||||||
|
case <-reloadCh:
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := ms.stop(); err != nil {
|
||||||
|
mainLog.Load().Warn().Err(err).Msg("could not stop metrics server")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
76
cmd/cli/net_darwin.go
Normal file
76
cmd/cli/net_darwin.go
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"bytes"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"os/exec"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
func patchNetIfaceName(iface *net.Interface) (bool, error) {
|
||||||
|
b, err := exec.Command("networksetup", "-listnetworkserviceorder").Output()
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
patched := false
|
||||||
|
if name := networkServiceName(iface.Name, bytes.NewReader(b)); name != "" {
|
||||||
|
patched = true
|
||||||
|
iface.Name = name
|
||||||
|
}
|
||||||
|
return patched, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func networkServiceName(ifaceName string, r io.Reader) string {
|
||||||
|
scanner := bufio.NewScanner(r)
|
||||||
|
prevLine := ""
|
||||||
|
for scanner.Scan() {
|
||||||
|
line := scanner.Text()
|
||||||
|
if strings.Contains(line, "*") {
|
||||||
|
// Network services is disabled.
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !strings.Contains(line, "Device: "+ifaceName) {
|
||||||
|
prevLine = line
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
parts := strings.SplitN(prevLine, " ", 2)
|
||||||
|
if len(parts) == 2 {
|
||||||
|
return strings.TrimSpace(parts[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// validInterface reports whether the *net.Interface is a valid one.
|
||||||
|
func validInterface(iface *net.Interface, validIfacesMap map[string]struct{}) bool {
|
||||||
|
_, ok := validIfacesMap[iface.Name]
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// validInterfacesMap returns a set of all valid hardware ports.
|
||||||
|
func validInterfacesMap() map[string]struct{} {
|
||||||
|
b, err := exec.Command("networksetup", "-listallhardwareports").Output()
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return parseListAllHardwarePorts(bytes.NewReader(b))
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseListAllHardwarePorts parses output of "networksetup -listallhardwareports"
|
||||||
|
// and returns map presents all hardware ports.
|
||||||
|
func parseListAllHardwarePorts(r io.Reader) map[string]struct{} {
|
||||||
|
m := make(map[string]struct{})
|
||||||
|
scanner := bufio.NewScanner(r)
|
||||||
|
for scanner.Scan() {
|
||||||
|
line := scanner.Text()
|
||||||
|
after, ok := strings.CutPrefix(line, "Device: ")
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
m[after] = struct{}{}
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
104
cmd/cli/net_darwin_test.go
Normal file
104
cmd/cli/net_darwin_test.go
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"maps"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
const listnetworkserviceorderOutput = `
|
||||||
|
(1) USB 10/100/1000 LAN 2
|
||||||
|
(Hardware Port: USB 10/100/1000 LAN, Device: en7)
|
||||||
|
|
||||||
|
(2) Ethernet
|
||||||
|
(Hardware Port: Ethernet, Device: en0)
|
||||||
|
|
||||||
|
(3) Wi-Fi
|
||||||
|
(Hardware Port: Wi-Fi, Device: en1)
|
||||||
|
|
||||||
|
(4) Bluetooth PAN
|
||||||
|
(Hardware Port: Bluetooth PAN, Device: en4)
|
||||||
|
|
||||||
|
(5) Thunderbolt Bridge
|
||||||
|
(Hardware Port: Thunderbolt Bridge, Device: bridge0)
|
||||||
|
|
||||||
|
(6) kernal
|
||||||
|
(Hardware Port: com.wireguard.macos, Device: )
|
||||||
|
|
||||||
|
(7) WS BT
|
||||||
|
(Hardware Port: com.wireguard.macos, Device: )
|
||||||
|
|
||||||
|
(8) ca-001-stg
|
||||||
|
(Hardware Port: com.wireguard.macos, Device: )
|
||||||
|
|
||||||
|
(9) ca-001-stg-2
|
||||||
|
(Hardware Port: com.wireguard.macos, Device: )
|
||||||
|
|
||||||
|
`
|
||||||
|
|
||||||
|
func Test_networkServiceName(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
ifaceName string
|
||||||
|
networkServiceName string
|
||||||
|
}{
|
||||||
|
{"en7", "USB 10/100/1000 LAN 2"},
|
||||||
|
{"en0", "Ethernet"},
|
||||||
|
{"en1", "Wi-Fi"},
|
||||||
|
{"en4", "Bluetooth PAN"},
|
||||||
|
{"bridge0", "Thunderbolt Bridge"},
|
||||||
|
}
|
||||||
|
for _, tc := range tests {
|
||||||
|
tc := tc
|
||||||
|
t.Run(tc.ifaceName, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
name := networkServiceName(tc.ifaceName, strings.NewReader(listnetworkserviceorderOutput))
|
||||||
|
assert.Equal(t, tc.networkServiceName, name)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const listallhardwareportsOutput = `
|
||||||
|
Hardware Port: Ethernet Adapter (en6)
|
||||||
|
Device: en6
|
||||||
|
Ethernet Address: 3a:3e:fc:1e:ab:41
|
||||||
|
|
||||||
|
Hardware Port: Ethernet Adapter (en7)
|
||||||
|
Device: en7
|
||||||
|
Ethernet Address: 3a:3e:fc:1e:ab:42
|
||||||
|
|
||||||
|
Hardware Port: Thunderbolt Bridge
|
||||||
|
Device: bridge0
|
||||||
|
Ethernet Address: 36:21:bb:3a:7a:40
|
||||||
|
|
||||||
|
Hardware Port: Wi-Fi
|
||||||
|
Device: en0
|
||||||
|
Ethernet Address: a0:78:17:68:56:3f
|
||||||
|
|
||||||
|
Hardware Port: Thunderbolt 1
|
||||||
|
Device: en1
|
||||||
|
Ethernet Address: 36:21:bb:3a:7a:40
|
||||||
|
|
||||||
|
Hardware Port: Thunderbolt 2
|
||||||
|
Device: en2
|
||||||
|
Ethernet Address: 36:21:bb:3a:7a:44
|
||||||
|
|
||||||
|
VLAN Configurations
|
||||||
|
===================
|
||||||
|
`
|
||||||
|
|
||||||
|
func Test_parseListAllHardwarePorts(t *testing.T) {
|
||||||
|
expected := map[string]struct{}{
|
||||||
|
"en0": {},
|
||||||
|
"en1": {},
|
||||||
|
"en2": {},
|
||||||
|
"en6": {},
|
||||||
|
"en7": {},
|
||||||
|
"bridge0": {},
|
||||||
|
}
|
||||||
|
m := parseListAllHardwarePorts(strings.NewReader(listallhardwareportsOutput))
|
||||||
|
if !maps.Equal(m, expected) {
|
||||||
|
t.Errorf("unexpected output, want: %v, got: %v", expected, m)
|
||||||
|
}
|
||||||
|
}
|
||||||
52
cmd/cli/net_linux.go
Normal file
52
cmd/cli/net_linux.go
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net"
|
||||||
|
"net/netip"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"tailscale.com/net/netmon"
|
||||||
|
)
|
||||||
|
|
||||||
|
func patchNetIfaceName(iface *net.Interface) (bool, error) { return true, nil }
|
||||||
|
|
||||||
|
// validInterface reports whether the *net.Interface is a valid one.
|
||||||
|
// Only non-virtual interfaces are considered valid.
|
||||||
|
func validInterface(iface *net.Interface, validIfacesMap map[string]struct{}) bool {
|
||||||
|
_, ok := validIfacesMap[iface.Name]
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// validInterfacesMap returns a set containing non virtual interfaces.
|
||||||
|
func validInterfacesMap() map[string]struct{} {
|
||||||
|
m := make(map[string]struct{})
|
||||||
|
vis := virtualInterfaces()
|
||||||
|
netmon.ForeachInterface(func(i netmon.Interface, prefixes []netip.Prefix) {
|
||||||
|
if _, existed := vis[i.Name]; existed {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
m[i.Name] = struct{}{}
|
||||||
|
})
|
||||||
|
// Fallback to default route interface if found nothing.
|
||||||
|
if len(m) == 0 {
|
||||||
|
defaultRoute, err := netmon.DefaultRoute()
|
||||||
|
if err != nil {
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
m[defaultRoute.InterfaceName] = struct{}{}
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
// virtualInterfaces returns a map of virtual interfaces on current machine.
|
||||||
|
func virtualInterfaces() map[string]struct{} {
|
||||||
|
s := make(map[string]struct{})
|
||||||
|
entries, _ := os.ReadDir("/sys/devices/virtual/net")
|
||||||
|
for _, entry := range entries {
|
||||||
|
if entry.IsDir() {
|
||||||
|
s[strings.TrimSpace(entry.Name())] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
22
cmd/cli/net_others.go
Normal file
22
cmd/cli/net_others.go
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
//go:build !darwin && !windows && !linux
|
||||||
|
|
||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net"
|
||||||
|
|
||||||
|
"tailscale.com/net/netmon"
|
||||||
|
)
|
||||||
|
|
||||||
|
func patchNetIfaceName(iface *net.Interface) (bool, error) { return true, nil }
|
||||||
|
|
||||||
|
func validInterface(iface *net.Interface, validIfacesMap map[string]struct{}) bool { return true }
|
||||||
|
|
||||||
|
// validInterfacesMap returns a set containing only default route interfaces.
|
||||||
|
func validInterfacesMap() map[string]struct{} {
|
||||||
|
defaultRoute, err := netmon.DefaultRoute()
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return map[string]struct{}{defaultRoute.InterfaceName: {}}
|
||||||
|
}
|
||||||
93
cmd/cli/net_windows.go
Normal file
93
cmd/cli/net_windows.go
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/microsoft/wmi/pkg/base/host"
|
||||||
|
"github.com/microsoft/wmi/pkg/base/instance"
|
||||||
|
"github.com/microsoft/wmi/pkg/base/query"
|
||||||
|
"github.com/microsoft/wmi/pkg/constant"
|
||||||
|
"github.com/microsoft/wmi/pkg/hardware/network/netadapter"
|
||||||
|
)
|
||||||
|
|
||||||
|
func patchNetIfaceName(iface *net.Interface) (bool, error) {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// validInterface reports whether the *net.Interface is a valid one.
|
||||||
|
// On Windows, only physical interfaces are considered valid.
|
||||||
|
func validInterface(iface *net.Interface, validIfacesMap map[string]struct{}) bool {
|
||||||
|
_, ok := validIfacesMap[iface.Name]
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// validInterfacesMap returns a set of all physical interfaces.
|
||||||
|
func validInterfacesMap() map[string]struct{} {
|
||||||
|
m := make(map[string]struct{})
|
||||||
|
for _, ifaceName := range validInterfaces() {
|
||||||
|
m[ifaceName] = struct{}{}
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
// validInterfaces returns a list of all physical interfaces.
|
||||||
|
func validInterfaces() []string {
|
||||||
|
log.SetOutput(io.Discard)
|
||||||
|
defer log.SetOutput(os.Stderr)
|
||||||
|
whost := host.NewWmiLocalHost()
|
||||||
|
q := query.NewWmiQuery("MSFT_NetAdapter")
|
||||||
|
instances, err := instance.GetWmiInstancesFromHost(whost, string(constant.StadardCimV2), q)
|
||||||
|
if instances != nil {
|
||||||
|
defer instances.Close()
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
mainLog.Load().Warn().Err(err).Msg("failed to get wmi network adapter")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var adapters []string
|
||||||
|
for _, i := range instances {
|
||||||
|
adapter, err := netadapter.NewNetworkAdapter(i)
|
||||||
|
if err != nil {
|
||||||
|
mainLog.Load().Warn().Err(err).Msg("failed to get network adapter")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
name, err := adapter.GetPropertyName()
|
||||||
|
if err != nil {
|
||||||
|
mainLog.Load().Warn().Err(err).Msg("failed to get interface name")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// From: https://learn.microsoft.com/en-us/previous-versions/windows/desktop/legacy/hh968170(v=vs.85)
|
||||||
|
//
|
||||||
|
// "Indicates if a connector is present on the network adapter. This value is set to TRUE
|
||||||
|
// if this is a physical adapter or FALSE if this is not a physical adapter."
|
||||||
|
physical, err := adapter.GetPropertyConnectorPresent()
|
||||||
|
if err != nil {
|
||||||
|
mainLog.Load().Debug().Str("method", "validInterfaces").Str("interface", name).Msg("failed to get network adapter connector present property")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !physical {
|
||||||
|
mainLog.Load().Debug().Str("method", "validInterfaces").Str("interface", name).Msg("skipping non-physical adapter")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if it's a hardware interface. Checking only for connector present is not enough
|
||||||
|
// because some interfaces are not physical but have a connector.
|
||||||
|
hardware, err := adapter.GetPropertyHardwareInterface()
|
||||||
|
if err != nil {
|
||||||
|
mainLog.Load().Debug().Str("method", "validInterfaces").Str("interface", name).Msg("failed to get network adapter hardware interface property")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !hardware {
|
||||||
|
mainLog.Load().Debug().Str("method", "validInterfaces").Str("interface", name).Msg("skipping non-hardware interface")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
adapters = append(adapters, name)
|
||||||
|
}
|
||||||
|
return adapters
|
||||||
|
}
|
||||||
42
cmd/cli/net_windows_test.go
Normal file
42
cmd/cli/net_windows_test.go
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"bytes"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Test_validInterfaces(t *testing.T) {
|
||||||
|
verbose = 3
|
||||||
|
initConsoleLogging()
|
||||||
|
start := time.Now()
|
||||||
|
ifaces := validInterfaces()
|
||||||
|
t.Logf("Using Windows API takes: %d", time.Since(start).Milliseconds())
|
||||||
|
|
||||||
|
start = time.Now()
|
||||||
|
ifacesPowershell := validInterfacesPowershell()
|
||||||
|
t.Logf("Using Powershell takes: %d", time.Since(start).Milliseconds())
|
||||||
|
|
||||||
|
slices.Sort(ifaces)
|
||||||
|
slices.Sort(ifacesPowershell)
|
||||||
|
if !slices.Equal(ifaces, ifacesPowershell) {
|
||||||
|
t.Fatalf("result mismatch, want: %v, got: %v", ifacesPowershell, ifaces)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func validInterfacesPowershell() []string {
|
||||||
|
out, err := powershell("Get-NetAdapter -Physical | Select-Object -ExpandProperty Name")
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var res []string
|
||||||
|
scanner := bufio.NewScanner(bytes.NewReader(out))
|
||||||
|
for scanner.Scan() {
|
||||||
|
ifaceName := strings.TrimSpace(scanner.Text())
|
||||||
|
res = append(res, ifaceName)
|
||||||
|
}
|
||||||
|
return res
|
||||||
|
}
|
||||||
34
cmd/cli/netlink_linux.go
Normal file
34
cmd/cli/netlink_linux.go
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/vishvananda/netlink"
|
||||||
|
"golang.org/x/sys/unix"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (p *prog) watchLinkState(ctx context.Context) {
|
||||||
|
ch := make(chan netlink.LinkUpdate)
|
||||||
|
done := make(chan struct{})
|
||||||
|
defer close(done)
|
||||||
|
if err := netlink.LinkSubscribe(ch, done); err != nil {
|
||||||
|
mainLog.Load().Warn().Err(err).Msg("could not subscribe link")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case lu := <-ch:
|
||||||
|
if lu.Change == 0xFFFFFFFF {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if lu.Change&unix.IFF_UP != 0 {
|
||||||
|
mainLog.Load().Debug().Msgf("link state changed, re-bootstrapping")
|
||||||
|
for _, uc := range p.cfg.Upstream {
|
||||||
|
uc.ReBootstrap()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
7
cmd/cli/netlink_others.go
Normal file
7
cmd/cli/netlink_others.go
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
//go:build !linux
|
||||||
|
|
||||||
|
package cli
|
||||||
|
|
||||||
|
import "context"
|
||||||
|
|
||||||
|
func (p *prog) watchLinkState(ctx context.Context) {}
|
||||||
89
cmd/cli/network_manager_linux.go
Normal file
89
cmd/cli/network_manager_linux.go
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/coreos/go-systemd/v22/dbus"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
nmConfDir = "/etc/NetworkManager/conf.d"
|
||||||
|
nmCtrldConfFilename = "99-ctrld.conf"
|
||||||
|
nmCtrldConfContent = `[main]
|
||||||
|
dns=none
|
||||||
|
systemd-resolved=false
|
||||||
|
`
|
||||||
|
nmSystemdUnitName = "NetworkManager.service"
|
||||||
|
)
|
||||||
|
|
||||||
|
var networkManagerCtrldConfFile = filepath.Join(nmConfDir, nmCtrldConfFilename)
|
||||||
|
|
||||||
|
// hasNetworkManager reports whether NetworkManager executable found.
|
||||||
|
func hasNetworkManager() bool {
|
||||||
|
exe, _ := exec.LookPath("NetworkManager")
|
||||||
|
return exe != ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func setupNetworkManager() error {
|
||||||
|
if !hasNetworkManager() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if content, _ := os.ReadFile(nmCtrldConfContent); string(content) == nmCtrldConfContent {
|
||||||
|
mainLog.Load().Debug().Msg("NetworkManager already setup, nothing to do")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
err := os.WriteFile(networkManagerCtrldConfFile, []byte(nmCtrldConfContent), os.FileMode(0644))
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
mainLog.Load().Debug().Msg("NetworkManager is not available")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
mainLog.Load().Debug().Err(err).Msg("could not write NetworkManager ctrld config file")
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
reloadNetworkManager()
|
||||||
|
mainLog.Load().Debug().Msg("setup NetworkManager done")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func restoreNetworkManager() error {
|
||||||
|
if !hasNetworkManager() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
err := os.Remove(networkManagerCtrldConfFile)
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
mainLog.Load().Debug().Msg("NetworkManager is not available")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
mainLog.Load().Debug().Err(err).Msg("could not remove NetworkManager ctrld config file")
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
reloadNetworkManager()
|
||||||
|
mainLog.Load().Debug().Msg("restore NetworkManager done")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func reloadNetworkManager() {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), time.Second*10)
|
||||||
|
defer cancel()
|
||||||
|
conn, err := dbus.NewSystemConnectionContext(ctx)
|
||||||
|
if err != nil {
|
||||||
|
mainLog.Load().Error().Err(err).Msg("could not create new system connection")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
|
||||||
|
waitCh := make(chan string)
|
||||||
|
if _, err := conn.ReloadUnitContext(ctx, nmSystemdUnitName, "ignore-dependencies", waitCh); err != nil {
|
||||||
|
mainLog.Load().Debug().Err(err).Msg("could not reload NetworkManager")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
<-waitCh
|
||||||
|
}
|
||||||
15
cmd/cli/network_manager_others.go
Normal file
15
cmd/cli/network_manager_others.go
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
//go:build !linux
|
||||||
|
|
||||||
|
package cli
|
||||||
|
|
||||||
|
func setupNetworkManager() error {
|
||||||
|
reloadNetworkManager()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func restoreNetworkManager() error {
|
||||||
|
reloadNetworkManager()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func reloadNetworkManager() {}
|
||||||
31
cmd/cli/nextdns.go
Normal file
31
cmd/cli/nextdns.go
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/Control-D-Inc/ctrld"
|
||||||
|
)
|
||||||
|
|
||||||
|
const nextdnsURL = "https://dns.nextdns.io"
|
||||||
|
|
||||||
|
func generateNextDNSConfig(uid string) {
|
||||||
|
if uid == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
mainLog.Load().Info().Msg("generating ctrld config for NextDNS resolver")
|
||||||
|
cfg = ctrld.Config{
|
||||||
|
Listener: map[string]*ctrld.ListenerConfig{
|
||||||
|
"0": {
|
||||||
|
IP: "0.0.0.0",
|
||||||
|
Port: 53,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Upstream: map[string]*ctrld.UpstreamConfig{
|
||||||
|
"0": {
|
||||||
|
Type: ctrld.ResolverTypeDOH3,
|
||||||
|
Endpoint: fmt.Sprintf("%s/%s", nextdnsURL, uid),
|
||||||
|
Timeout: 5000,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
5
cmd/cli/nocgo.go
Normal file
5
cmd/cli/nocgo.go
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
//go:build !cgo
|
||||||
|
|
||||||
|
package cli
|
||||||
|
|
||||||
|
const cgoEnabled = false
|
||||||
114
cmd/cli/os_darwin.go
Normal file
114
cmd/cli/os_darwin.go
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"bytes"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"os/exec"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/Control-D-Inc/ctrld/internal/resolvconffile"
|
||||||
|
)
|
||||||
|
|
||||||
|
// allocate loopback ip
|
||||||
|
// sudo ifconfig lo0 alias 127.0.0.2 up
|
||||||
|
func allocateIP(ip string) error {
|
||||||
|
cmd := exec.Command("ifconfig", "lo0", "alias", ip, "up")
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
mainLog.Load().Error().Err(err).Msg("allocateIP failed")
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func deAllocateIP(ip string) error {
|
||||||
|
cmd := exec.Command("ifconfig", "lo0", "-alias", ip)
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
mainLog.Load().Error().Err(err).Msg("deAllocateIP failed")
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// setDnsIgnoreUnusableInterface likes setDNS, but return a nil error if the interface is not usable.
|
||||||
|
func setDnsIgnoreUnusableInterface(iface *net.Interface, nameservers []string) error {
|
||||||
|
if err := setDNS(iface, nameservers); err != nil {
|
||||||
|
// TODO: investiate whether we can detect this without relying on error message.
|
||||||
|
if strings.Contains(err.Error(), " is not a recognized network service") {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// set the dns server for the provided network interface
|
||||||
|
// networksetup -setdnsservers Wi-Fi 8.8.8.8 1.1.1.1
|
||||||
|
// TODO(cuonglm): use system API
|
||||||
|
func setDNS(iface *net.Interface, nameservers []string) error {
|
||||||
|
// Note that networksetup won't modify search domains settings,
|
||||||
|
// This assignment is just a placeholder to silent linter.
|
||||||
|
_ = searchDomains
|
||||||
|
cmd := "networksetup"
|
||||||
|
args := []string{"-setdnsservers", iface.Name}
|
||||||
|
args = append(args, nameservers...)
|
||||||
|
if out, err := exec.Command(cmd, args...).CombinedOutput(); err != nil {
|
||||||
|
return fmt.Errorf("%v: %w", string(out), err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// resetDnsIgnoreUnusableInterface likes resetDNS, but return a nil error if the interface is not usable.
|
||||||
|
func resetDnsIgnoreUnusableInterface(iface *net.Interface) error {
|
||||||
|
if err := resetDNS(iface); err != nil {
|
||||||
|
// TODO: investiate whether we can detect this without relying on error message.
|
||||||
|
if strings.Contains(err.Error(), " is not a recognized network service") {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO(cuonglm): use system API
|
||||||
|
func resetDNS(iface *net.Interface) error {
|
||||||
|
cmd := "networksetup"
|
||||||
|
args := []string{"-setdnsservers", iface.Name, "empty"}
|
||||||
|
if out, err := exec.Command(cmd, args...).CombinedOutput(); err != nil {
|
||||||
|
return fmt.Errorf("%v: %w", string(out), err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// restoreDNS restores the DNS settings of the given interface.
|
||||||
|
// this should only be executed upon turning off the ctrld service.
|
||||||
|
func restoreDNS(iface *net.Interface) (err error) {
|
||||||
|
if ns := savedStaticNameservers(iface); len(ns) > 0 {
|
||||||
|
err = setDNS(iface, ns)
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func currentDNS(_ *net.Interface) []string {
|
||||||
|
return resolvconffile.NameServers()
|
||||||
|
}
|
||||||
|
|
||||||
|
// currentStaticDNS returns the current static DNS settings of given interface.
|
||||||
|
func currentStaticDNS(iface *net.Interface) ([]string, error) {
|
||||||
|
cmd := "networksetup"
|
||||||
|
args := []string{"-getdnsservers", iface.Name}
|
||||||
|
out, err := exec.Command(cmd, args...).Output()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
scanner := bufio.NewScanner(bytes.NewReader(out))
|
||||||
|
var ns []string
|
||||||
|
for scanner.Scan() {
|
||||||
|
line := scanner.Text()
|
||||||
|
if ip := net.ParseIP(line); ip != nil {
|
||||||
|
ns = append(ns, ip.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ns, nil
|
||||||
|
}
|
||||||
103
cmd/cli/os_freebsd.go
Normal file
103
cmd/cli/os_freebsd.go
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net"
|
||||||
|
"net/netip"
|
||||||
|
"os/exec"
|
||||||
|
|
||||||
|
"tailscale.com/control/controlknobs"
|
||||||
|
"tailscale.com/health"
|
||||||
|
"tailscale.com/util/dnsname"
|
||||||
|
|
||||||
|
"github.com/Control-D-Inc/ctrld/internal/dns"
|
||||||
|
"github.com/Control-D-Inc/ctrld/internal/resolvconffile"
|
||||||
|
)
|
||||||
|
|
||||||
|
// allocate loopback ip
|
||||||
|
// sudo ifconfig lo0 127.0.0.53 alias
|
||||||
|
func allocateIP(ip string) error {
|
||||||
|
cmd := exec.Command("ifconfig", "lo0", ip, "alias")
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
mainLog.Load().Error().Err(err).Msg("allocateIP failed")
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func deAllocateIP(ip string) error {
|
||||||
|
cmd := exec.Command("ifconfig", "lo0", ip, "-alias")
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
mainLog.Load().Error().Err(err).Msg("deAllocateIP failed")
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// setDnsIgnoreUnusableInterface likes setDNS, but return a nil error if the interface is not usable.
|
||||||
|
func setDnsIgnoreUnusableInterface(iface *net.Interface, nameservers []string) error {
|
||||||
|
return setDNS(iface, nameservers)
|
||||||
|
}
|
||||||
|
|
||||||
|
// set the dns server for the provided network interface
|
||||||
|
func setDNS(iface *net.Interface, nameservers []string) error {
|
||||||
|
r, err := dns.NewOSConfigurator(logf, &health.Tracker{}, &controlknobs.Knobs{}, iface.Name)
|
||||||
|
if err != nil {
|
||||||
|
mainLog.Load().Error().Err(err).Msg("failed to create DNS OS configurator")
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
ns := make([]netip.Addr, 0, len(nameservers))
|
||||||
|
for _, nameserver := range nameservers {
|
||||||
|
ns = append(ns, netip.MustParseAddr(nameserver))
|
||||||
|
}
|
||||||
|
|
||||||
|
osConfig := dns.OSConfig{
|
||||||
|
Nameservers: ns,
|
||||||
|
SearchDomains: []dnsname.FQDN{},
|
||||||
|
}
|
||||||
|
if sds, err := searchDomains(); err == nil {
|
||||||
|
osConfig.SearchDomains = sds
|
||||||
|
} else {
|
||||||
|
mainLog.Load().Debug().Err(err).Msg("failed to get search domains list")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := r.SetDNS(osConfig); err != nil {
|
||||||
|
mainLog.Load().Error().Err(err).Msg("failed to set DNS")
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// resetDnsIgnoreUnusableInterface likes resetDNS, but return a nil error if the interface is not usable.
|
||||||
|
func resetDnsIgnoreUnusableInterface(iface *net.Interface) error {
|
||||||
|
return resetDNS(iface)
|
||||||
|
}
|
||||||
|
|
||||||
|
func resetDNS(iface *net.Interface) error {
|
||||||
|
r, err := dns.NewOSConfigurator(logf, &health.Tracker{}, &controlknobs.Knobs{}, iface.Name)
|
||||||
|
if err != nil {
|
||||||
|
mainLog.Load().Error().Err(err).Msg("failed to create DNS OS configurator")
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := r.Close(); err != nil {
|
||||||
|
mainLog.Load().Error().Err(err).Msg("failed to rollback DNS setting")
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// restoreDNS restores the DNS settings of the given interface.
|
||||||
|
// this should only be executed upon turning off the ctrld service.
|
||||||
|
func restoreDNS(iface *net.Interface) (err error) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func currentDNS(_ *net.Interface) []string {
|
||||||
|
return resolvconffile.NameServers()
|
||||||
|
}
|
||||||
|
|
||||||
|
// currentStaticDNS returns the current static DNS settings of given interface.
|
||||||
|
func currentStaticDNS(iface *net.Interface) ([]string, error) {
|
||||||
|
return currentDNS(iface), nil
|
||||||
|
}
|
||||||
306
cmd/cli/os_linux.go
Normal file
306
cmd/cli/os_linux.go
Normal file
@@ -0,0 +1,306 @@
|
|||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"net/netip"
|
||||||
|
"os/exec"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/insomniacslk/dhcp/dhcpv4/nclient4"
|
||||||
|
"github.com/insomniacslk/dhcp/dhcpv6"
|
||||||
|
"github.com/insomniacslk/dhcp/dhcpv6/client6"
|
||||||
|
"tailscale.com/control/controlknobs"
|
||||||
|
"tailscale.com/health"
|
||||||
|
"tailscale.com/util/dnsname"
|
||||||
|
|
||||||
|
"github.com/Control-D-Inc/ctrld/internal/dns"
|
||||||
|
ctrldnet "github.com/Control-D-Inc/ctrld/internal/net"
|
||||||
|
"github.com/Control-D-Inc/ctrld/internal/resolvconffile"
|
||||||
|
)
|
||||||
|
|
||||||
|
const resolvConfBackupFailedMsg = "open /etc/resolv.pre-ctrld-backup.conf: read-only file system"
|
||||||
|
|
||||||
|
// allocate loopback ip
|
||||||
|
// sudo ip a add 127.0.0.2/24 dev lo
|
||||||
|
func allocateIP(ip string) error {
|
||||||
|
cmd := exec.Command("ip", "a", "add", ip+"/24", "dev", "lo")
|
||||||
|
if out, err := cmd.CombinedOutput(); err != nil {
|
||||||
|
mainLog.Load().Error().Err(err).Msgf("allocateIP failed: %s", string(out))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func deAllocateIP(ip string) error {
|
||||||
|
cmd := exec.Command("ip", "a", "del", ip+"/24", "dev", "lo")
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
mainLog.Load().Error().Err(err).Msg("deAllocateIP failed")
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxSetDNSAttempts = 5
|
||||||
|
|
||||||
|
// setDnsIgnoreUnusableInterface likes setDNS, but return a nil error if the interface is not usable.
|
||||||
|
func setDnsIgnoreUnusableInterface(iface *net.Interface, nameservers []string) error {
|
||||||
|
return setDNS(iface, nameservers)
|
||||||
|
}
|
||||||
|
|
||||||
|
func setDNS(iface *net.Interface, nameservers []string) error {
|
||||||
|
r, err := dns.NewOSConfigurator(logf, &health.Tracker{}, &controlknobs.Knobs{}, iface.Name)
|
||||||
|
if err != nil {
|
||||||
|
mainLog.Load().Error().Err(err).Msg("failed to create DNS OS configurator")
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
ns := make([]netip.Addr, 0, len(nameservers))
|
||||||
|
for _, nameserver := range nameservers {
|
||||||
|
ns = append(ns, netip.MustParseAddr(nameserver))
|
||||||
|
}
|
||||||
|
|
||||||
|
osConfig := dns.OSConfig{
|
||||||
|
Nameservers: ns,
|
||||||
|
SearchDomains: []dnsname.FQDN{},
|
||||||
|
}
|
||||||
|
if sds, err := searchDomains(); err == nil {
|
||||||
|
osConfig.SearchDomains = sds
|
||||||
|
} else {
|
||||||
|
mainLog.Load().Debug().Err(err).Msg("failed to get search domains list")
|
||||||
|
}
|
||||||
|
trySystemdResolve := false
|
||||||
|
if err := r.SetDNS(osConfig); err != nil {
|
||||||
|
if strings.Contains(err.Error(), "Rejected send message") &&
|
||||||
|
strings.Contains(err.Error(), "org.freedesktop.network1.Manager") {
|
||||||
|
mainLog.Load().Warn().Msg("Interfaces are managed by systemd-networkd, switch to systemd-resolve for setting DNS")
|
||||||
|
trySystemdResolve = true
|
||||||
|
goto systemdResolve
|
||||||
|
}
|
||||||
|
// This error happens on read-only file system, which causes ctrld failed to create backup
|
||||||
|
// for /etc/resolv.conf file. It is ok, because the DNS is still set anyway, and restore
|
||||||
|
// DNS will fallback to use DHCP if there's no backup /etc/resolv.conf file.
|
||||||
|
// The error format is controlled by us, so checking for error string is fine.
|
||||||
|
// See: ../../internal/dns/direct.go:L278
|
||||||
|
if r.Mode() == "direct" && strings.Contains(err.Error(), resolvConfBackupFailedMsg) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
systemdResolve:
|
||||||
|
if trySystemdResolve {
|
||||||
|
// Stop systemd-networkd and retry setting DNS.
|
||||||
|
if out, err := exec.Command("systemctl", "stop", "systemd-networkd").CombinedOutput(); err != nil {
|
||||||
|
return fmt.Errorf("%s: %w", string(out), err)
|
||||||
|
}
|
||||||
|
args := []string{"--interface=" + iface.Name, "--set-domain=~"}
|
||||||
|
for _, nameserver := range nameservers {
|
||||||
|
args = append(args, "--set-dns="+nameserver)
|
||||||
|
}
|
||||||
|
for i := 0; i < maxSetDNSAttempts; i++ {
|
||||||
|
if out, err := exec.Command("systemd-resolve", args...).CombinedOutput(); err != nil {
|
||||||
|
return fmt.Errorf("%s: %w", string(out), err)
|
||||||
|
}
|
||||||
|
currentNS := currentDNS(iface)
|
||||||
|
if isSubSet(nameservers, currentNS) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
time.Sleep(time.Second)
|
||||||
|
}
|
||||||
|
mainLog.Load().Debug().Msg("DNS was not set for some reason")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// resetDnsIgnoreUnusableInterface likes resetDNS, but return a nil error if the interface is not usable.
|
||||||
|
func resetDnsIgnoreUnusableInterface(iface *net.Interface) error {
|
||||||
|
return resetDNS(iface)
|
||||||
|
}
|
||||||
|
|
||||||
|
func resetDNS(iface *net.Interface) (err error) {
|
||||||
|
defer func() {
|
||||||
|
if err == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Start systemd-networkd if present.
|
||||||
|
if exe, _ := exec.LookPath("/lib/systemd/systemd-networkd"); exe != "" {
|
||||||
|
_ = exec.Command("systemctl", "start", "systemd-networkd").Run()
|
||||||
|
}
|
||||||
|
if r, oerr := dns.NewOSConfigurator(logf, &health.Tracker{}, &controlknobs.Knobs{}, iface.Name); oerr == nil {
|
||||||
|
_ = r.SetDNS(dns.OSConfig{})
|
||||||
|
if err := r.Close(); err != nil {
|
||||||
|
mainLog.Load().Error().Err(err).Msg("failed to rollback DNS setting")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
err = nil
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
var ns []string
|
||||||
|
c, err := nclient4.New(iface.Name)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("nclient4.New: %w", err)
|
||||||
|
}
|
||||||
|
defer c.Close()
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), time.Second*5)
|
||||||
|
defer cancel()
|
||||||
|
lease, err := c.Request(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("nclient4.Request: %w", err)
|
||||||
|
}
|
||||||
|
for _, nameserver := range lease.ACK.DNS() {
|
||||||
|
if nameserver.Equal(net.IPv4zero) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ns = append(ns, nameserver.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO(cuonglm): handle DHCPv6 properly.
|
||||||
|
mainLog.Load().Debug().Msg("checking for IPv6 availability")
|
||||||
|
if ctrldnet.IPv6Available(ctx) {
|
||||||
|
c := client6.NewClient()
|
||||||
|
conversation, err := c.Exchange(iface.Name)
|
||||||
|
if err != nil && !errAddrInUse(err) {
|
||||||
|
mainLog.Load().Debug().Err(err).Msg("could not exchange DHCPv6")
|
||||||
|
}
|
||||||
|
for _, packet := range conversation {
|
||||||
|
if packet.Type() == dhcpv6.MessageTypeReply {
|
||||||
|
msg, err := packet.GetInnerMessage()
|
||||||
|
if err != nil {
|
||||||
|
mainLog.Load().Debug().Err(err).Msg("could not get inner DHCPv6 message")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
nameservers := msg.Options.DNS()
|
||||||
|
for _, nameserver := range nameservers {
|
||||||
|
ns = append(ns, nameserver.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
mainLog.Load().Debug().Msg("IPv6 is not available")
|
||||||
|
}
|
||||||
|
|
||||||
|
return ignoringEINTR(func() error {
|
||||||
|
return setDNS(iface, ns)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// restoreDNS restores the DNS settings of the given interface.
|
||||||
|
// this should only be executed upon turning off the ctrld service.
|
||||||
|
func restoreDNS(iface *net.Interface) (err error) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func currentDNS(iface *net.Interface) []string {
|
||||||
|
resolvconfFunc := func(_ string) []string { return resolvconffile.NameServers() }
|
||||||
|
for _, fn := range []getDNS{getDNSByResolvectl, getDNSBySystemdResolved, getDNSByNmcli, resolvconfFunc} {
|
||||||
|
if ns := fn(iface.Name); len(ns) > 0 {
|
||||||
|
return ns
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// currentStaticDNS returns the current static DNS settings of given interface.
|
||||||
|
func currentStaticDNS(iface *net.Interface) ([]string, error) {
|
||||||
|
return currentDNS(iface), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func getDNSByResolvectl(iface string) []string {
|
||||||
|
b, err := exec.Command("resolvectl", "dns", "-i", iface).Output()
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
parts := strings.Fields(strings.SplitN(string(b), "%", 2)[0])
|
||||||
|
if len(parts) > 2 {
|
||||||
|
return parts[3:]
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func getDNSBySystemdResolved(iface string) []string {
|
||||||
|
b, err := exec.Command("systemd-resolve", "--status", iface).Output()
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return getDNSBySystemdResolvedFromReader(bytes.NewReader(b))
|
||||||
|
}
|
||||||
|
|
||||||
|
func getDNSBySystemdResolvedFromReader(r io.Reader) []string {
|
||||||
|
scanner := bufio.NewScanner(r)
|
||||||
|
var ret []string
|
||||||
|
for scanner.Scan() {
|
||||||
|
line := strings.TrimSpace(scanner.Text())
|
||||||
|
if len(ret) > 0 {
|
||||||
|
if net.ParseIP(line) != nil {
|
||||||
|
ret = append(ret, line)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
after, found := strings.CutPrefix(line, "DNS Servers: ")
|
||||||
|
if !found {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if net.ParseIP(after) != nil {
|
||||||
|
ret = append(ret, after)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ret
|
||||||
|
}
|
||||||
|
|
||||||
|
func getDNSByNmcli(iface string) []string {
|
||||||
|
b, err := exec.Command("nmcli", "dev", "show", iface).Output()
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
s := bufio.NewScanner(bytes.NewReader(b))
|
||||||
|
var dns []string
|
||||||
|
do := func(line string) {
|
||||||
|
parts := strings.SplitN(line, ":", 2)
|
||||||
|
if len(parts) > 1 {
|
||||||
|
dns = append(dns, strings.TrimSpace(parts[1]))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for s.Scan() {
|
||||||
|
line := s.Text()
|
||||||
|
switch {
|
||||||
|
case strings.HasPrefix(line, "IP4.DNS"):
|
||||||
|
fallthrough
|
||||||
|
case strings.HasPrefix(line, "IP6.DNS"):
|
||||||
|
do(line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return dns
|
||||||
|
}
|
||||||
|
|
||||||
|
func ignoringEINTR(fn func() error) error {
|
||||||
|
for {
|
||||||
|
err := fn()
|
||||||
|
if err != syscall.EINTR {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// isSubSet reports whether s2 contains all elements of s1.
|
||||||
|
func isSubSet(s1, s2 []string) bool {
|
||||||
|
ok := true
|
||||||
|
for _, ns := range s1 {
|
||||||
|
if slices.Contains(s2, ns) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ok = false
|
||||||
|
break
|
||||||
|
}
|
||||||
|
return ok
|
||||||
|
}
|
||||||
23
cmd/cli/os_linux_test.go
Normal file
23
cmd/cli/os_linux_test.go
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Test_getDNSBySystemdResolvedFromReader(t *testing.T) {
|
||||||
|
r := strings.NewReader(`Link 2 (eth0)
|
||||||
|
Current Scopes: DNS
|
||||||
|
LLMNR setting: yes
|
||||||
|
MulticastDNS setting: no
|
||||||
|
DNSSEC setting: no
|
||||||
|
DNSSEC supported: no
|
||||||
|
DNS Servers: 8.8.8.8
|
||||||
|
8.8.4.4`)
|
||||||
|
want := []string{"8.8.8.8", "8.8.4.4"}
|
||||||
|
ns := getDNSBySystemdResolvedFromReader(r)
|
||||||
|
if !reflect.DeepEqual(ns, want) {
|
||||||
|
t.Logf("unexpected result, want: %v, got: %v", want, ns)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
//go:build windows
|
//go:build !linux && !darwin && !freebsd
|
||||||
// +build windows
|
|
||||||
|
|
||||||
package main
|
package cli
|
||||||
|
|
||||||
// TODO(cuonglm): implement.
|
// TODO(cuonglm): implement.
|
||||||
func allocateIP(ip string) error {
|
func allocateIP(ip string) error {
|
||||||
332
cmd/cli/os_windows.go
Normal file
332
cmd/cli/os_windows.go
Normal file
@@ -0,0 +1,332 @@
|
|||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"net/netip"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"golang.org/x/sys/windows"
|
||||||
|
"golang.org/x/sys/windows/registry"
|
||||||
|
"golang.zx2c4.com/wireguard/windows/tunnel/winipcfg"
|
||||||
|
|
||||||
|
ctrldnet "github.com/Control-D-Inc/ctrld/internal/net"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
v4InterfaceKeyPathFormat = `SYSTEM\CurrentControlSet\Services\Tcpip\Parameters\Interfaces\`
|
||||||
|
v6InterfaceKeyPathFormat = `SYSTEM\CurrentControlSet\Services\Tcpip6\Parameters\Interfaces\`
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
setDNSOnce sync.Once
|
||||||
|
resetDNSOnce sync.Once
|
||||||
|
)
|
||||||
|
|
||||||
|
// setDnsIgnoreUnusableInterface likes setDNS, but return a nil error if the interface is not usable.
|
||||||
|
func setDnsIgnoreUnusableInterface(iface *net.Interface, nameservers []string) error {
|
||||||
|
return setDNS(iface, nameservers)
|
||||||
|
}
|
||||||
|
|
||||||
|
// setDNS sets the dns server for the provided network interface
|
||||||
|
func setDNS(iface *net.Interface, nameservers []string) error {
|
||||||
|
if len(nameservers) == 0 {
|
||||||
|
return errors.New("empty DNS nameservers")
|
||||||
|
}
|
||||||
|
setDNSOnce.Do(func() {
|
||||||
|
// If there's a Dns server running, that means we are on AD with Dns feature enabled.
|
||||||
|
// Configuring the Dns server to forward queries to ctrld instead.
|
||||||
|
if hasLocalDnsServerRunning() {
|
||||||
|
mainLog.Load().Debug().Msg("Local DNS server detected, configuring forwarders")
|
||||||
|
|
||||||
|
file := absHomeDir(windowsForwardersFilename)
|
||||||
|
mainLog.Load().Debug().Msgf("Using forwarders file: %s", file)
|
||||||
|
|
||||||
|
oldForwardersContent, err := os.ReadFile(file)
|
||||||
|
if err != nil {
|
||||||
|
mainLog.Load().Debug().Err(err).Msg("Could not read existing forwarders file")
|
||||||
|
} else {
|
||||||
|
mainLog.Load().Debug().Msgf("Existing forwarders content: %s", string(oldForwardersContent))
|
||||||
|
}
|
||||||
|
|
||||||
|
hasLocalIPv6Listener := needLocalIPv6Listener()
|
||||||
|
mainLog.Load().Debug().Bool("has_ipv6_listener", hasLocalIPv6Listener).Msg("IPv6 listener status")
|
||||||
|
|
||||||
|
forwarders := slices.DeleteFunc(slices.Clone(nameservers), func(s string) bool {
|
||||||
|
if !hasLocalIPv6Listener {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return s == "::1"
|
||||||
|
})
|
||||||
|
mainLog.Load().Debug().Strs("forwarders", forwarders).Msg("Filtered forwarders list")
|
||||||
|
|
||||||
|
if err := os.WriteFile(file, []byte(strings.Join(forwarders, ",")), 0600); err != nil {
|
||||||
|
mainLog.Load().Warn().Err(err).Msg("could not save forwarders settings")
|
||||||
|
} else {
|
||||||
|
mainLog.Load().Debug().Msg("Successfully wrote new forwarders file")
|
||||||
|
}
|
||||||
|
|
||||||
|
oldForwarders := strings.Split(string(oldForwardersContent), ",")
|
||||||
|
mainLog.Load().Debug().Strs("old_forwarders", oldForwarders).Msg("Previous forwarders")
|
||||||
|
|
||||||
|
if err := addDnsServerForwarders(forwarders, oldForwarders); err != nil {
|
||||||
|
mainLog.Load().Warn().Err(err).Msg("could not set forwarders settings")
|
||||||
|
} else {
|
||||||
|
mainLog.Load().Debug().Msg("Successfully configured DNS server forwarders")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
luid, err := winipcfg.LUIDFromIndex(uint32(iface.Index))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("setDNS: %w", err)
|
||||||
|
}
|
||||||
|
var (
|
||||||
|
serversV4 []netip.Addr
|
||||||
|
serversV6 []netip.Addr
|
||||||
|
)
|
||||||
|
for _, ns := range nameservers {
|
||||||
|
if addr, err := netip.ParseAddr(ns); err == nil {
|
||||||
|
if addr.Is4() {
|
||||||
|
serversV4 = append(serversV4, addr)
|
||||||
|
} else {
|
||||||
|
serversV6 = append(serversV6, addr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Note that Windows won't modify the current search domains if passing nil to luid.SetDNS function.
|
||||||
|
// searchDomains is still implemented for Windows just in case Windows API changes in future versions.
|
||||||
|
_ = searchDomains
|
||||||
|
|
||||||
|
if len(serversV4) == 0 && len(serversV6) == 0 {
|
||||||
|
return errors.New("invalid DNS nameservers")
|
||||||
|
}
|
||||||
|
if len(serversV4) > 0 {
|
||||||
|
if err := luid.SetDNS(windows.AF_INET, serversV4, nil); err != nil {
|
||||||
|
return fmt.Errorf("could not set DNS ipv4: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(serversV6) > 0 {
|
||||||
|
if err := luid.SetDNS(windows.AF_INET6, serversV6, nil); err != nil {
|
||||||
|
return fmt.Errorf("could not set DNS ipv6: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// resetDnsIgnoreUnusableInterface likes resetDNS, but return a nil error if the interface is not usable.
|
||||||
|
func resetDnsIgnoreUnusableInterface(iface *net.Interface) error {
|
||||||
|
return resetDNS(iface)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO(cuonglm): should we use system API?
|
||||||
|
func resetDNS(iface *net.Interface) error {
|
||||||
|
resetDNSOnce.Do(func() {
|
||||||
|
// See corresponding comment in setDNS.
|
||||||
|
if hasLocalDnsServerRunning() {
|
||||||
|
file := absHomeDir(windowsForwardersFilename)
|
||||||
|
content, err := os.ReadFile(file)
|
||||||
|
if err != nil {
|
||||||
|
mainLog.Load().Error().Err(err).Msg("could not read forwarders settings")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
nameservers := strings.Split(string(content), ",")
|
||||||
|
if err := removeDnsServerForwarders(nameservers); err != nil {
|
||||||
|
mainLog.Load().Error().Err(err).Msg("could not remove forwarders settings")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
luid, err := winipcfg.LUIDFromIndex(uint32(iface.Index))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("resetDNS: %w", err)
|
||||||
|
}
|
||||||
|
// Restoring DHCP settings.
|
||||||
|
if err := luid.SetDNS(windows.AF_INET, nil, nil); err != nil {
|
||||||
|
return fmt.Errorf("could not reset DNS ipv4: %w", err)
|
||||||
|
}
|
||||||
|
if err := luid.SetDNS(windows.AF_INET6, nil, nil); err != nil {
|
||||||
|
return fmt.Errorf("could not reset DNS ipv6: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// restoreDNS restores the DNS settings of the given interface.
|
||||||
|
// this should only be executed upon turning off the ctrld service.
|
||||||
|
func restoreDNS(iface *net.Interface) (err error) {
|
||||||
|
if nss := savedStaticNameservers(iface); len(nss) > 0 {
|
||||||
|
v4ns := make([]string, 0, 2)
|
||||||
|
v6ns := make([]string, 0, 2)
|
||||||
|
for _, ns := range nss {
|
||||||
|
if ctrldnet.IsIPv6(ns) {
|
||||||
|
v6ns = append(v6ns, ns)
|
||||||
|
} else {
|
||||||
|
v4ns = append(v4ns, ns)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
luid, err := winipcfg.LUIDFromIndex(uint32(iface.Index))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("restoreDNS: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(v4ns) > 0 {
|
||||||
|
mainLog.Load().Debug().Msgf("restoring IPv4 static DNS for interface %q: %v", iface.Name, v4ns)
|
||||||
|
if err := setDNS(iface, v4ns); err != nil {
|
||||||
|
return fmt.Errorf("restoreDNS (IPv4): %w", err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
mainLog.Load().Debug().Msgf("restoring IPv4 DHCP for interface %q", iface.Name)
|
||||||
|
if err := luid.SetDNS(windows.AF_INET, nil, nil); err != nil {
|
||||||
|
return fmt.Errorf("restoreDNS (IPv4 clear): %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(v6ns) > 0 {
|
||||||
|
mainLog.Load().Debug().Msgf("restoring IPv6 static DNS for interface %q: %v", iface.Name, v6ns)
|
||||||
|
if err := setDNS(iface, v6ns); err != nil {
|
||||||
|
return fmt.Errorf("restoreDNS (IPv6): %w", err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
mainLog.Load().Debug().Msgf("restoring IPv6 DHCP for interface %q", iface.Name)
|
||||||
|
if err := luid.SetDNS(windows.AF_INET6, nil, nil); err != nil {
|
||||||
|
return fmt.Errorf("restoreDNS (IPv6 clear): %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func currentDNS(iface *net.Interface) []string {
|
||||||
|
luid, err := winipcfg.LUIDFromIndex(uint32(iface.Index))
|
||||||
|
if err != nil {
|
||||||
|
mainLog.Load().Error().Err(err).Msg("failed to get interface LUID")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
nameservers, err := luid.DNS()
|
||||||
|
if err != nil {
|
||||||
|
mainLog.Load().Error().Err(err).Msg("failed to get interface DNS")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
ns := make([]string, 0, len(nameservers))
|
||||||
|
for _, nameserver := range nameservers {
|
||||||
|
ns = append(ns, nameserver.String())
|
||||||
|
}
|
||||||
|
return ns
|
||||||
|
}
|
||||||
|
|
||||||
|
// currentStaticDNS checks both the IPv4 and IPv6 paths for static DNS values using keys
|
||||||
|
// like "NameServer" and "ProfileNameServer".
|
||||||
|
func currentStaticDNS(iface *net.Interface) ([]string, error) {
|
||||||
|
luid, err := winipcfg.LUIDFromIndex(uint32(iface.Index))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("fallback winipcfg.LUIDFromIndex: %w", err)
|
||||||
|
}
|
||||||
|
guid, err := luid.GUID()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("fallback luid.GUID: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var ns []string
|
||||||
|
keyPaths := []string{v4InterfaceKeyPathFormat, v6InterfaceKeyPathFormat}
|
||||||
|
for _, path := range keyPaths {
|
||||||
|
interfaceKeyPath := path + guid.String()
|
||||||
|
k, err := registry.OpenKey(registry.LOCAL_MACHINE, interfaceKeyPath, registry.QUERY_VALUE)
|
||||||
|
if err != nil {
|
||||||
|
mainLog.Load().Debug().Err(err).Msgf("failed to open registry key %q for interface %q; trying next key", interfaceKeyPath, iface.Name)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
func() {
|
||||||
|
defer k.Close()
|
||||||
|
for _, keyName := range []string{"NameServer", "ProfileNameServer"} {
|
||||||
|
value, _, err := k.GetStringValue(keyName)
|
||||||
|
if err != nil && !errors.Is(err, registry.ErrNotExist) {
|
||||||
|
mainLog.Load().Debug().Err(err).Msgf("error reading %s registry key", keyName)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if len(value) > 0 {
|
||||||
|
mainLog.Load().Debug().Msgf("found static DNS for interface %q: %s", iface.Name, value)
|
||||||
|
parsed := parseDNSServers(value)
|
||||||
|
for _, pns := range parsed {
|
||||||
|
if !slices.Contains(ns, pns) {
|
||||||
|
ns = append(ns, pns)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
if len(ns) == 0 {
|
||||||
|
mainLog.Load().Debug().Msgf("no static DNS values found for interface %q", iface.Name)
|
||||||
|
}
|
||||||
|
return ns, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseDNSServers splits a DNS server string that may be comma- or space-separated,
|
||||||
|
// and trims any extraneous whitespace or null characters.
|
||||||
|
func parseDNSServers(val string) []string {
|
||||||
|
fields := strings.FieldsFunc(val, func(r rune) bool {
|
||||||
|
return r == ' ' || r == ','
|
||||||
|
})
|
||||||
|
var servers []string
|
||||||
|
for _, f := range fields {
|
||||||
|
trimmed := strings.TrimSpace(f)
|
||||||
|
if len(trimmed) > 0 {
|
||||||
|
servers = append(servers, trimmed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return servers
|
||||||
|
}
|
||||||
|
|
||||||
|
// addDnsServerForwarders adds given nameservers to DNS server forwarders list,
|
||||||
|
// and also removing old forwarders if provided.
|
||||||
|
func addDnsServerForwarders(nameservers, old []string) error {
|
||||||
|
newForwardersMap := make(map[string]struct{})
|
||||||
|
newForwarders := make([]string, len(nameservers))
|
||||||
|
for i := range nameservers {
|
||||||
|
newForwardersMap[nameservers[i]] = struct{}{}
|
||||||
|
newForwarders[i] = fmt.Sprintf("%q", nameservers[i])
|
||||||
|
}
|
||||||
|
oldForwarders := old[:0]
|
||||||
|
for _, fwd := range old {
|
||||||
|
if _, ok := newForwardersMap[fwd]; !ok {
|
||||||
|
oldForwarders = append(oldForwarders, fwd)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// NOTE: It is important to add new forwarder before removing old one.
|
||||||
|
// Testing on Windows Server 2022 shows that removing forwarder1
|
||||||
|
// then adding forwarder2 sometimes ends up adding both of them
|
||||||
|
// to the forwarders list.
|
||||||
|
cmd := fmt.Sprintf("Add-DnsServerForwarder -IPAddress %s", strings.Join(newForwarders, ","))
|
||||||
|
if len(oldForwarders) > 0 {
|
||||||
|
cmd = fmt.Sprintf("%s ; Remove-DnsServerForwarder -IPAddress %s -Force", cmd, strings.Join(oldForwarders, ","))
|
||||||
|
}
|
||||||
|
if out, err := powershell(cmd); err != nil {
|
||||||
|
return fmt.Errorf("%w: %s", err, string(out))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// removeDnsServerForwarders removes given nameservers from DNS server forwarders list.
|
||||||
|
func removeDnsServerForwarders(nameservers []string) error {
|
||||||
|
for _, ns := range nameservers {
|
||||||
|
cmd := fmt.Sprintf("Remove-DnsServerForwarder -IPAddress %s -Force", ns)
|
||||||
|
if out, err := powershell(cmd); err != nil {
|
||||||
|
return fmt.Errorf("%w: %s", err, string(out))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// powershell runs the given powershell command.
|
||||||
|
func powershell(cmd string) ([]byte, error) {
|
||||||
|
out, err := exec.Command("powershell", "-Command", cmd).CombinedOutput()
|
||||||
|
return bytes.TrimSpace(out), err
|
||||||
|
}
|
||||||
68
cmd/cli/os_windows_test.go
Normal file
68
cmd/cli/os_windows_test.go
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"golang.zx2c4.com/wireguard/windows/tunnel/winipcfg"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Test_currentStaticDNS(t *testing.T) {
|
||||||
|
iface, err := net.InterfaceByName(defaultIfaceName())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
start := time.Now()
|
||||||
|
staticDns, err := currentStaticDNS(iface)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Logf("Using Windows API takes: %d", time.Since(start).Milliseconds())
|
||||||
|
|
||||||
|
start = time.Now()
|
||||||
|
staticDnsPowershell, err := currentStaticDnsPowershell(iface)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Logf("Using Powershell takes: %d", time.Since(start).Milliseconds())
|
||||||
|
|
||||||
|
slices.Sort(staticDns)
|
||||||
|
slices.Sort(staticDnsPowershell)
|
||||||
|
if !slices.Equal(staticDns, staticDnsPowershell) {
|
||||||
|
t.Fatalf("result mismatch, want: %v, got: %v", staticDnsPowershell, staticDns)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func currentStaticDnsPowershell(iface *net.Interface) ([]string, error) {
|
||||||
|
luid, err := winipcfg.LUIDFromIndex(uint32(iface.Index))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
guid, err := luid.GUID()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var ns []string
|
||||||
|
for _, path := range []string{"HKLM:\\" + v4InterfaceKeyPathFormat, "HKLM:\\" + v6InterfaceKeyPathFormat} {
|
||||||
|
interfaceKeyPath := path + guid.String()
|
||||||
|
found := false
|
||||||
|
for _, key := range []string{"NameServer", "ProfileNameServer"} {
|
||||||
|
if found {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
cmd := fmt.Sprintf(`Get-ItemPropertyValue -Path "%s" -Name "%s"`, interfaceKeyPath, key)
|
||||||
|
out, err := powershell(cmd)
|
||||||
|
if err == nil && len(out) > 0 {
|
||||||
|
found = true
|
||||||
|
for _, e := range strings.Split(string(out), ",") {
|
||||||
|
ns = append(ns, strings.TrimRight(e, "\x00"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ns, nil
|
||||||
|
}
|
||||||
1541
cmd/cli/prog.go
Normal file
1541
cmd/cli/prog.go
Normal file
File diff suppressed because it is too large
Load Diff
11
cmd/cli/prog_darwin.go
Normal file
11
cmd/cli/prog_darwin.go
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/kardianos/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
func setDependencies(svc *service.Config) {}
|
||||||
|
|
||||||
|
func setWorkingDirectory(svc *service.Config, dir string) {
|
||||||
|
svc.WorkingDirectory = dir
|
||||||
|
}
|
||||||
14
cmd/cli/prog_freebsd.go
Normal file
14
cmd/cli/prog_freebsd.go
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/kardianos/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
func setDependencies(svc *service.Config) {
|
||||||
|
// TODO(cuonglm): remove once https://github.com/kardianos/service/issues/359 fixed.
|
||||||
|
_ = os.MkdirAll("/usr/local/etc/rc.d", 0755)
|
||||||
|
}
|
||||||
|
|
||||||
|
func setWorkingDirectory(svc *service.Config, dir string) {}
|
||||||
68
cmd/cli/prog_linux.go
Normal file
68
cmd/cli/prog_linux.go
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"bytes"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/kardianos/service"
|
||||||
|
|
||||||
|
"github.com/Control-D-Inc/ctrld/internal/router"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
if isAndroid() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if r, err := newLoopbackOSConfigurator(); err == nil {
|
||||||
|
useSystemdResolved = r.Mode() == "systemd-resolved"
|
||||||
|
}
|
||||||
|
// Disable quic-go's ECN support by default, see https://github.com/quic-go/quic-go/issues/3911
|
||||||
|
if os.Getenv("QUIC_GO_DISABLE_ECN") == "" {
|
||||||
|
os.Setenv("QUIC_GO_DISABLE_ECN", "true")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func setDependencies(svc *service.Config) {
|
||||||
|
svc.Dependencies = []string{
|
||||||
|
"Wants=network-online.target",
|
||||||
|
"After=network-online.target",
|
||||||
|
"Wants=NetworkManager-wait-online.service",
|
||||||
|
"After=NetworkManager-wait-online.service",
|
||||||
|
"Wants=nss-lookup.target",
|
||||||
|
"After=nss-lookup.target",
|
||||||
|
}
|
||||||
|
if out, _ := exec.Command("networkctl", "--no-pager").CombinedOutput(); len(out) > 0 {
|
||||||
|
if wantsSystemDNetworkdWaitOnline(bytes.NewReader(out)) {
|
||||||
|
svc.Dependencies = append(svc.Dependencies, "Wants=systemd-networkd-wait-online.service")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if routerDeps := router.ServiceDependencies(); len(routerDeps) > 0 {
|
||||||
|
svc.Dependencies = append(svc.Dependencies, routerDeps...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func setWorkingDirectory(svc *service.Config, dir string) {
|
||||||
|
svc.WorkingDirectory = dir
|
||||||
|
}
|
||||||
|
|
||||||
|
// wantsSystemDNetworkdWaitOnline reports whether "systemd-networkd-wait-online" service
|
||||||
|
// is required to be added to ctrld dependencies services.
|
||||||
|
// The input reader r is the output of "networkctl --no-pager" command.
|
||||||
|
func wantsSystemDNetworkdWaitOnline(r io.Reader) bool {
|
||||||
|
scanner := bufio.NewScanner(r)
|
||||||
|
// Skip header
|
||||||
|
scanner.Scan()
|
||||||
|
configured := false
|
||||||
|
for scanner.Scan() {
|
||||||
|
fields := strings.Fields(scanner.Text())
|
||||||
|
if len(fields) > 0 && fields[len(fields)-1] == "configured" {
|
||||||
|
configured = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return configured
|
||||||
|
}
|
||||||
48
cmd/cli/prog_linux_test.go
Normal file
48
cmd/cli/prog_linux_test.go
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
networkctlUnmanagedOutput = `IDX LINK TYPE OPERATIONAL SETUP
|
||||||
|
1 lo loopback carrier unmanaged
|
||||||
|
2 wlp0s20f3 wlan routable unmanaged
|
||||||
|
3 tailscale0 none routable unmanaged
|
||||||
|
4 br-9ac33145e060 bridge no-carrier unmanaged
|
||||||
|
5 docker0 bridge no-carrier unmanaged
|
||||||
|
|
||||||
|
5 links listed.
|
||||||
|
`
|
||||||
|
networkctlManagedOutput = `IDX LINK TYPE OPERATIONAL SETUP
|
||||||
|
1 lo loopback carrier unmanaged
|
||||||
|
2 wlp0s20f3 wlan routable configured
|
||||||
|
3 tailscale0 none routable unmanaged
|
||||||
|
4 br-9ac33145e060 bridge no-carrier unmanaged
|
||||||
|
5 docker0 bridge no-carrier unmanaged
|
||||||
|
|
||||||
|
5 links listed.
|
||||||
|
`
|
||||||
|
)
|
||||||
|
|
||||||
|
func Test_wantsSystemDNetworkdWaitOnline(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
r io.Reader
|
||||||
|
required bool
|
||||||
|
}{
|
||||||
|
{"unmanaged", strings.NewReader(networkctlUnmanagedOutput), false},
|
||||||
|
{"managed", strings.NewReader(networkctlManagedOutput), true},
|
||||||
|
{"empty", strings.NewReader(""), false},
|
||||||
|
}
|
||||||
|
for _, tc := range tests {
|
||||||
|
tc := tc
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
if required := wantsSystemDNetworkdWaitOnline(tc.r); required != tc.required {
|
||||||
|
t.Errorf("wants %v got %v", tc.required, required)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
12
cmd/cli/prog_others.go
Normal file
12
cmd/cli/prog_others.go
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
//go:build !linux && !freebsd && !darwin && !windows
|
||||||
|
|
||||||
|
package cli
|
||||||
|
|
||||||
|
import "github.com/kardianos/service"
|
||||||
|
|
||||||
|
func setDependencies(svc *service.Config) {}
|
||||||
|
|
||||||
|
func setWorkingDirectory(svc *service.Config, dir string) {
|
||||||
|
// WorkingDirectory is not supported on Windows.
|
||||||
|
svc.WorkingDirectory = dir
|
||||||
|
}
|
||||||
57
cmd/cli/prog_test.go
Normal file
57
cmd/cli/prog_test.go
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/Control-D-Inc/ctrld"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Test_prog_dnsWatchdogEnabled(t *testing.T) {
|
||||||
|
p := &prog{cfg: &ctrld.Config{}}
|
||||||
|
|
||||||
|
// Default value is true.
|
||||||
|
assert.True(t, p.dnsWatchdogEnabled())
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
enabled bool
|
||||||
|
}{
|
||||||
|
{"enabled", true},
|
||||||
|
{"disabled", false},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
tc := tc
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
p.cfg.Service.DnsWatchdogEnabled = &tc.enabled
|
||||||
|
assert.Equal(t, tc.enabled, p.dnsWatchdogEnabled())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func Test_prog_dnsWatchdogInterval(t *testing.T) {
|
||||||
|
p := &prog{cfg: &ctrld.Config{}}
|
||||||
|
|
||||||
|
// Default value is 20s.
|
||||||
|
assert.Equal(t, dnsWatchdogDefaultInterval, p.dnsWatchdogDuration())
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
duration time.Duration
|
||||||
|
expected time.Duration
|
||||||
|
}{
|
||||||
|
{"valid", time.Minute, time.Minute},
|
||||||
|
{"zero", 0, dnsWatchdogDefaultInterval},
|
||||||
|
{"nagative", time.Duration(-1 * time.Minute), dnsWatchdogDefaultInterval},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
tc := tc
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
p.cfg.Service.DnsWatchdogInvterval = &tc.duration
|
||||||
|
assert.Equal(t, tc.expected, p.dnsWatchdogDuration())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
14
cmd/cli/prog_windows.go
Normal file
14
cmd/cli/prog_windows.go
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
package cli
|
||||||
|
|
||||||
|
import "github.com/kardianos/service"
|
||||||
|
|
||||||
|
func setDependencies(svc *service.Config) {
|
||||||
|
if hasLocalDnsServerRunning() {
|
||||||
|
svc.Dependencies = []string{"DNS"}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func setWorkingDirectory(svc *service.Config, dir string) {
|
||||||
|
// WorkingDirectory is not supported on Windows.
|
||||||
|
svc.WorkingDirectory = dir
|
||||||
|
}
|
||||||
57
cmd/cli/prometheus.go
Normal file
57
cmd/cli/prometheus.go
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
package cli
|
||||||
|
|
||||||
|
import "github.com/prometheus/client_golang/prometheus"
|
||||||
|
|
||||||
|
const (
|
||||||
|
metricsLabelListener = "listener"
|
||||||
|
metricsLabelClientSourceIP = "client_source_ip"
|
||||||
|
metricsLabelClientMac = "client_mac"
|
||||||
|
metricsLabelClientHostname = "client_hostname"
|
||||||
|
metricsLabelUpstream = "upstream"
|
||||||
|
metricsLabelRRType = "rr_type"
|
||||||
|
metricsLabelRCode = "rcode"
|
||||||
|
)
|
||||||
|
|
||||||
|
// statsVersion represent ctrld version.
|
||||||
|
var statsVersion = prometheus.NewCounterVec(prometheus.CounterOpts{
|
||||||
|
Name: "ctrld_build_info",
|
||||||
|
Help: "Version of ctrld process.",
|
||||||
|
}, []string{"gitref", "goversion", "version"})
|
||||||
|
|
||||||
|
// statsTimeStart represents start time of ctrld service.
|
||||||
|
var statsTimeStart = prometheus.NewGauge(prometheus.GaugeOpts{
|
||||||
|
Name: "ctrld_time_seconds",
|
||||||
|
Help: "Start time of the ctrld process since unix epoch in seconds.",
|
||||||
|
})
|
||||||
|
|
||||||
|
var statsQueriesCountLabels = []string{
|
||||||
|
metricsLabelListener,
|
||||||
|
metricsLabelClientSourceIP,
|
||||||
|
metricsLabelClientMac,
|
||||||
|
metricsLabelClientHostname,
|
||||||
|
metricsLabelUpstream,
|
||||||
|
metricsLabelRRType,
|
||||||
|
metricsLabelRCode,
|
||||||
|
}
|
||||||
|
|
||||||
|
// statsQueriesCount counts total number of queries.
|
||||||
|
var statsQueriesCount = prometheus.NewCounterVec(prometheus.CounterOpts{
|
||||||
|
Name: "ctrld_queries_count",
|
||||||
|
Help: "Total number of queries.",
|
||||||
|
}, statsQueriesCountLabels)
|
||||||
|
|
||||||
|
// statsClientQueriesCount counts total number of queries of a client.
|
||||||
|
//
|
||||||
|
// The labels "client_source_ip", "client_mac", "client_hostname" are unbounded,
|
||||||
|
// thus this stat is highly inefficient if there are many devices.
|
||||||
|
var statsClientQueriesCount = prometheus.NewCounterVec(prometheus.CounterOpts{
|
||||||
|
Name: "ctrld_client_queries_count",
|
||||||
|
Help: "Total number queries of a client.",
|
||||||
|
}, []string{metricsLabelClientSourceIP, metricsLabelClientMac, metricsLabelClientHostname})
|
||||||
|
|
||||||
|
// WithLabelValuesInc increases prometheus counter by 1 if query stats is enabled.
|
||||||
|
func (p *prog) WithLabelValuesInc(c *prometheus.CounterVec, lvs ...string) {
|
||||||
|
if p.metricsQueryStats.Load() {
|
||||||
|
c.WithLabelValues(lvs...).Inc()
|
||||||
|
}
|
||||||
|
}
|
||||||
17
cmd/cli/reload_others.go
Normal file
17
cmd/cli/reload_others.go
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
//go:build !windows
|
||||||
|
|
||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"syscall"
|
||||||
|
)
|
||||||
|
|
||||||
|
func notifyReloadSigCh(ch chan os.Signal) {
|
||||||
|
signal.Notify(ch, syscall.SIGUSR1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *prog) sendReloadSignal() error {
|
||||||
|
return syscall.Kill(syscall.Getpid(), syscall.SIGUSR1)
|
||||||
|
}
|
||||||
18
cmd/cli/reload_windows.go
Normal file
18
cmd/cli/reload_windows.go
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func notifyReloadSigCh(ch chan os.Signal) {}
|
||||||
|
|
||||||
|
func (p *prog) sendReloadSignal() error {
|
||||||
|
select {
|
||||||
|
case p.reloadCh <- struct{}{}:
|
||||||
|
return nil
|
||||||
|
case <-time.After(5 * time.Second):
|
||||||
|
}
|
||||||
|
return errors.New("timeout while sending reload signal")
|
||||||
|
}
|
||||||
164
cmd/cli/resolvconf.go
Normal file
164
cmd/cli/resolvconf.go
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net"
|
||||||
|
"net/netip"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/fsnotify/fsnotify"
|
||||||
|
)
|
||||||
|
|
||||||
|
// parseResolvConfNameservers reads the resolv.conf file and returns the nameservers found.
|
||||||
|
// Returns nil if no nameservers are found.
|
||||||
|
func (p *prog) parseResolvConfNameservers(path string) ([]string, error) {
|
||||||
|
content, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse the file for "nameserver" lines
|
||||||
|
var currentNS []string
|
||||||
|
lines := strings.Split(string(content), "\n")
|
||||||
|
for _, line := range lines {
|
||||||
|
trimmed := strings.TrimSpace(line)
|
||||||
|
if strings.HasPrefix(trimmed, "nameserver") {
|
||||||
|
parts := strings.Fields(trimmed)
|
||||||
|
if len(parts) >= 2 {
|
||||||
|
currentNS = append(currentNS, parts[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return currentNS, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// watchResolvConf watches any changes to /etc/resolv.conf file,
|
||||||
|
// and reverting to the original config set by ctrld.
|
||||||
|
func (p *prog) watchResolvConf(iface *net.Interface, ns []netip.Addr, setDnsFn func(iface *net.Interface, ns []netip.Addr) error) {
|
||||||
|
resolvConfPath := "/etc/resolv.conf"
|
||||||
|
// Evaluating symbolics link to watch the target file that /etc/resolv.conf point to.
|
||||||
|
if rp, _ := filepath.EvalSymlinks(resolvConfPath); rp != "" {
|
||||||
|
resolvConfPath = rp
|
||||||
|
}
|
||||||
|
mainLog.Load().Debug().Msgf("start watching %s file", resolvConfPath)
|
||||||
|
watcher, err := fsnotify.NewWatcher()
|
||||||
|
if err != nil {
|
||||||
|
mainLog.Load().Warn().Err(err).Msg("could not create watcher for /etc/resolv.conf")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer watcher.Close()
|
||||||
|
|
||||||
|
// We watch /etc instead of /etc/resolv.conf directly,
|
||||||
|
// see: https://github.com/fsnotify/fsnotify#watching-a-file-doesnt-work-well
|
||||||
|
watchDir := filepath.Dir(resolvConfPath)
|
||||||
|
if err := watcher.Add(watchDir); err != nil {
|
||||||
|
mainLog.Load().Warn().Err(err).Msgf("could not add %s to watcher list", watchDir)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-p.dnsWatcherStopCh:
|
||||||
|
return
|
||||||
|
case <-p.stopCh:
|
||||||
|
mainLog.Load().Debug().Msgf("stopping watcher for %s", resolvConfPath)
|
||||||
|
return
|
||||||
|
case event, ok := <-watcher.Events:
|
||||||
|
if p.recoveryRunning.Load() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if event.Name != resolvConfPath { // skip if not /etc/resolv.conf changes.
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if event.Has(fsnotify.Write) || event.Has(fsnotify.Create) {
|
||||||
|
mainLog.Load().Debug().Msgf("/etc/resolv.conf changes detected, reading changes...")
|
||||||
|
|
||||||
|
// Convert expected nameservers to strings for comparison
|
||||||
|
expectedNS := make([]string, len(ns))
|
||||||
|
for i, addr := range ns {
|
||||||
|
expectedNS[i] = addr.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
var foundNS []string
|
||||||
|
var err error
|
||||||
|
|
||||||
|
maxRetries := 1
|
||||||
|
for retry := 0; retry < maxRetries; retry++ {
|
||||||
|
foundNS, err = p.parseResolvConfNameservers(resolvConfPath)
|
||||||
|
if err != nil {
|
||||||
|
mainLog.Load().Error().Err(err).Msg("failed to read resolv.conf content")
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we found nameservers, break out of retry loop
|
||||||
|
if len(foundNS) > 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only retry if we found no nameservers
|
||||||
|
if retry < maxRetries-1 {
|
||||||
|
mainLog.Load().Debug().Msgf("resolv.conf has no nameserver entries, retry %d/%d in 2 seconds", retry+1, maxRetries)
|
||||||
|
select {
|
||||||
|
case <-p.stopCh:
|
||||||
|
return
|
||||||
|
case <-p.dnsWatcherStopCh:
|
||||||
|
return
|
||||||
|
case <-time.After(2 * time.Second):
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
mainLog.Load().Debug().Msg("resolv.conf remained empty after all retries")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we found nameservers, check if they match what we expect
|
||||||
|
if len(foundNS) > 0 {
|
||||||
|
// Check if the nameservers match exactly what we expect
|
||||||
|
matches := len(foundNS) == len(expectedNS)
|
||||||
|
if matches {
|
||||||
|
for i := range foundNS {
|
||||||
|
if foundNS[i] != expectedNS[i] {
|
||||||
|
matches = false
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mainLog.Load().Debug().
|
||||||
|
Strs("found", foundNS).
|
||||||
|
Strs("expected", expectedNS).
|
||||||
|
Bool("matches", matches).
|
||||||
|
Msg("checking nameservers")
|
||||||
|
|
||||||
|
// Only revert if the nameservers don't match
|
||||||
|
if !matches {
|
||||||
|
if err := watcher.Remove(watchDir); err != nil {
|
||||||
|
mainLog.Load().Error().Err(err).Msg("failed to pause watcher")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := setDnsFn(iface, ns); err != nil {
|
||||||
|
mainLog.Load().Error().Err(err).Msg("failed to revert /etc/resolv.conf changes")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := watcher.Add(watchDir); err != nil {
|
||||||
|
mainLog.Load().Error().Err(err).Msg("failed to continue running watcher")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case err, ok := <-watcher.Errors:
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
mainLog.Load().Err(err).Msg("could not get event for /etc/resolv.conf")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
49
cmd/cli/resolvconf_darwin.go
Normal file
49
cmd/cli/resolvconf_darwin.go
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net"
|
||||||
|
"net/netip"
|
||||||
|
"os"
|
||||||
|
"slices"
|
||||||
|
|
||||||
|
"github.com/Control-D-Inc/ctrld/internal/dns/resolvconffile"
|
||||||
|
)
|
||||||
|
|
||||||
|
const resolvConfPath = "/etc/resolv.conf"
|
||||||
|
|
||||||
|
// setResolvConf sets the content of resolv.conf file using the given nameservers list.
|
||||||
|
func setResolvConf(iface *net.Interface, ns []netip.Addr) error {
|
||||||
|
servers := make([]string, len(ns))
|
||||||
|
for i := range ns {
|
||||||
|
servers[i] = ns[i].String()
|
||||||
|
}
|
||||||
|
if err := setDNS(iface, servers); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
slices.Sort(servers)
|
||||||
|
curNs := currentDNS(iface)
|
||||||
|
slices.Sort(curNs)
|
||||||
|
if !slices.Equal(curNs, servers) {
|
||||||
|
c, err := resolvconffile.ParseFile(resolvConfPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
c.Nameservers = ns
|
||||||
|
f, err := os.Create(resolvConfPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
if err := c.Write(f); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return f.Close()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// shouldWatchResolvconf reports whether ctrld should watch changes to resolv.conf file with given OS configurator.
|
||||||
|
func shouldWatchResolvconf() bool {
|
||||||
|
return true
|
||||||
|
}
|
||||||
52
cmd/cli/resolvconf_not_darwin_unix.go
Normal file
52
cmd/cli/resolvconf_not_darwin_unix.go
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
//go:build unix && !darwin
|
||||||
|
|
||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net"
|
||||||
|
"net/netip"
|
||||||
|
|
||||||
|
"tailscale.com/control/controlknobs"
|
||||||
|
"tailscale.com/health"
|
||||||
|
"tailscale.com/util/dnsname"
|
||||||
|
|
||||||
|
"github.com/Control-D-Inc/ctrld/internal/dns"
|
||||||
|
)
|
||||||
|
|
||||||
|
// setResolvConf sets the content of the resolv.conf file using the given nameservers list.
|
||||||
|
func setResolvConf(iface *net.Interface, ns []netip.Addr) error {
|
||||||
|
r, err := newLoopbackOSConfigurator()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
oc := dns.OSConfig{
|
||||||
|
Nameservers: ns,
|
||||||
|
SearchDomains: []dnsname.FQDN{},
|
||||||
|
}
|
||||||
|
if sds, err := searchDomains(); err == nil {
|
||||||
|
oc.SearchDomains = sds
|
||||||
|
} else {
|
||||||
|
mainLog.Load().Debug().Err(err).Msg("failed to get search domains list when reverting resolv.conf file")
|
||||||
|
}
|
||||||
|
return r.SetDNS(oc)
|
||||||
|
}
|
||||||
|
|
||||||
|
// shouldWatchResolvconf reports whether ctrld should watch changes to resolv.conf file with given OS configurator.
|
||||||
|
func shouldWatchResolvconf() bool {
|
||||||
|
r, err := newLoopbackOSConfigurator()
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
switch r.Mode() {
|
||||||
|
case "direct", "resolvconf":
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// newLoopbackOSConfigurator creates an OSConfigurator for DNS management using the "lo" interface.
|
||||||
|
func newLoopbackOSConfigurator() (dns.OSConfigurator, error) {
|
||||||
|
return dns.NewOSConfigurator(noopLogf, &health.Tracker{}, &controlknobs.Knobs{}, "lo")
|
||||||
|
}
|
||||||
16
cmd/cli/resolvconf_windows.go
Normal file
16
cmd/cli/resolvconf_windows.go
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net"
|
||||||
|
"net/netip"
|
||||||
|
)
|
||||||
|
|
||||||
|
// setResolvConf sets the content of resolv.conf file using the given nameservers list.
|
||||||
|
func setResolvConf(_ *net.Interface, _ []netip.Addr) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// shouldWatchResolvconf reports whether ctrld should watch changes to resolv.conf file with given OS configurator.
|
||||||
|
func shouldWatchResolvconf() bool {
|
||||||
|
return false
|
||||||
|
}
|
||||||
14
cmd/cli/search_domains_unix.go
Normal file
14
cmd/cli/search_domains_unix.go
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
//go:build unix
|
||||||
|
|
||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"tailscale.com/util/dnsname"
|
||||||
|
|
||||||
|
"github.com/Control-D-Inc/ctrld/internal/resolvconffile"
|
||||||
|
)
|
||||||
|
|
||||||
|
// searchDomains returns the current search domains config.
|
||||||
|
func searchDomains() ([]dnsname.FQDN, error) {
|
||||||
|
return resolvconffile.SearchDomains()
|
||||||
|
}
|
||||||
43
cmd/cli/search_domains_windows.go
Normal file
43
cmd/cli/search_domains_windows.go
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"syscall"
|
||||||
|
|
||||||
|
"golang.zx2c4.com/wireguard/windows/tunnel/winipcfg"
|
||||||
|
"tailscale.com/util/dnsname"
|
||||||
|
)
|
||||||
|
|
||||||
|
// searchDomains returns the current search domains config.
|
||||||
|
func searchDomains() ([]dnsname.FQDN, error) {
|
||||||
|
flags := winipcfg.GAAFlagIncludeGateways |
|
||||||
|
winipcfg.GAAFlagIncludePrefix
|
||||||
|
|
||||||
|
aas, err := winipcfg.GetAdaptersAddresses(syscall.AF_UNSPEC, flags)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("winipcfg.GetAdaptersAddresses: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var sds []dnsname.FQDN
|
||||||
|
for _, aa := range aas {
|
||||||
|
if aa.OperStatus != winipcfg.IfOperStatusUp {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip if software loopback or other non-physical types
|
||||||
|
// This is to avoid the "Loopback Pseudo-Interface 1" issue we see on windows
|
||||||
|
if aa.IfType == winipcfg.IfTypeSoftwareLoopback {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
for a := aa.FirstDNSSuffix; a != nil; a = a.Next {
|
||||||
|
d, err := dnsname.ToFQDN(a.String())
|
||||||
|
if err != nil {
|
||||||
|
mainLog.Load().Debug().Err(err).Msgf("failed to parse domain: %s", a.String())
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
sds = append(sds, d)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sds, nil
|
||||||
|
}
|
||||||
7
cmd/cli/self_delete_others.go
Normal file
7
cmd/cli/self_delete_others.go
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
//go:build !windows
|
||||||
|
|
||||||
|
package cli
|
||||||
|
|
||||||
|
var supportedSelfDelete = true
|
||||||
|
|
||||||
|
func selfDeleteExe() error { return nil }
|
||||||
134
cmd/cli/self_delete_windows.go
Normal file
134
cmd/cli/self_delete_windows.go
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
// Copied from https://github.com/secur30nly/go-self-delete
|
||||||
|
// with modification to suitable for ctrld usage.
|
||||||
|
|
||||||
|
/*
|
||||||
|
License: MIT Licence
|
||||||
|
|
||||||
|
References:
|
||||||
|
- https://github.com/LloydLabs/delete-self-poc
|
||||||
|
- https://twitter.com/jonasLyk/status/1350401461985955840
|
||||||
|
*/
|
||||||
|
|
||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"unsafe"
|
||||||
|
|
||||||
|
"golang.org/x/sys/windows"
|
||||||
|
)
|
||||||
|
|
||||||
|
var supportedSelfDelete = false
|
||||||
|
|
||||||
|
type FILE_RENAME_INFO struct {
|
||||||
|
Union struct {
|
||||||
|
ReplaceIfExists bool
|
||||||
|
Flags uint32
|
||||||
|
}
|
||||||
|
RootDirectory windows.Handle
|
||||||
|
FileNameLength uint32
|
||||||
|
FileName [1]uint16
|
||||||
|
}
|
||||||
|
|
||||||
|
type FILE_DISPOSITION_INFO struct {
|
||||||
|
DeleteFile bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func dsOpenHandle(pwPath *uint16) (windows.Handle, error) {
|
||||||
|
handle, err := windows.CreateFile(
|
||||||
|
pwPath,
|
||||||
|
windows.DELETE,
|
||||||
|
0,
|
||||||
|
nil,
|
||||||
|
windows.OPEN_EXISTING,
|
||||||
|
windows.FILE_ATTRIBUTE_NORMAL,
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return handle, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func dsRenameHandle(hHandle windows.Handle) error {
|
||||||
|
var fRename FILE_RENAME_INFO
|
||||||
|
DS_STREAM_RENAME, err := windows.UTF16FromString(":deadbeef")
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
lpwStream := &DS_STREAM_RENAME[0]
|
||||||
|
fRename.FileNameLength = uint32(unsafe.Sizeof(lpwStream))
|
||||||
|
|
||||||
|
windows.NewLazyDLL("kernel32.dll").NewProc("RtlCopyMemory").Call(
|
||||||
|
uintptr(unsafe.Pointer(&fRename.FileName[0])),
|
||||||
|
uintptr(unsafe.Pointer(lpwStream)),
|
||||||
|
unsafe.Sizeof(lpwStream),
|
||||||
|
)
|
||||||
|
|
||||||
|
err = windows.SetFileInformationByHandle(
|
||||||
|
hHandle,
|
||||||
|
windows.FileRenameInfo,
|
||||||
|
(*byte)(unsafe.Pointer(&fRename)),
|
||||||
|
uint32(unsafe.Sizeof(fRename)+unsafe.Sizeof(lpwStream)),
|
||||||
|
)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func dsDepositeHandle(hHandle windows.Handle) error {
|
||||||
|
var fDelete FILE_DISPOSITION_INFO
|
||||||
|
fDelete.DeleteFile = true
|
||||||
|
|
||||||
|
err := windows.SetFileInformationByHandle(
|
||||||
|
hHandle,
|
||||||
|
windows.FileDispositionInfo,
|
||||||
|
(*byte)(unsafe.Pointer(&fDelete)),
|
||||||
|
uint32(unsafe.Sizeof(fDelete)),
|
||||||
|
)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func selfDeleteExe() error {
|
||||||
|
var wcPath [windows.MAX_PATH + 1]uint16
|
||||||
|
var hCurrent windows.Handle
|
||||||
|
|
||||||
|
_, err := windows.GetModuleFileName(0, &wcPath[0], windows.MAX_PATH)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
hCurrent, err = dsOpenHandle(&wcPath[0])
|
||||||
|
if err != nil || hCurrent == windows.InvalidHandle {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := dsRenameHandle(hCurrent); err != nil {
|
||||||
|
_ = windows.CloseHandle(hCurrent)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_ = windows.CloseHandle(hCurrent)
|
||||||
|
|
||||||
|
hCurrent, err = dsOpenHandle(&wcPath[0])
|
||||||
|
if err != nil || hCurrent == windows.InvalidHandle {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := dsDepositeHandle(hCurrent); err != nil {
|
||||||
|
_ = windows.CloseHandle(hCurrent)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return windows.CloseHandle(hCurrent)
|
||||||
|
}
|
||||||
16
cmd/cli/self_kill_others.go
Normal file
16
cmd/cli/self_kill_others.go
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
//go:build !unix
|
||||||
|
|
||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/rs/zerolog"
|
||||||
|
)
|
||||||
|
|
||||||
|
func selfUninstall(p *prog, logger zerolog.Logger) {
|
||||||
|
if uninstallInvalidCdUID(p, logger, false) {
|
||||||
|
logger.Warn().Msgf("service was uninstalled because device %q does not exist", cdUID)
|
||||||
|
os.Exit(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
45
cmd/cli/self_kill_unix.go
Normal file
45
cmd/cli/self_kill_unix.go
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
//go:build unix
|
||||||
|
|
||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"runtime"
|
||||||
|
"syscall"
|
||||||
|
|
||||||
|
"github.com/rs/zerolog"
|
||||||
|
)
|
||||||
|
|
||||||
|
func selfUninstall(p *prog, logger zerolog.Logger) {
|
||||||
|
if runtime.GOOS == "linux" {
|
||||||
|
selfUninstallLinux(p, logger)
|
||||||
|
}
|
||||||
|
|
||||||
|
bin, err := os.Executable()
|
||||||
|
if err != nil {
|
||||||
|
logger.Fatal().Err(err).Msg("could not determine executable")
|
||||||
|
}
|
||||||
|
args := []string{"uninstall"}
|
||||||
|
if deactivationPinSet() {
|
||||||
|
args = append(args, fmt.Sprintf("--pin=%d", cdDeactivationPin.Load()))
|
||||||
|
}
|
||||||
|
cmd := exec.Command(bin, args...)
|
||||||
|
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
||||||
|
if err := cmd.Start(); err != nil {
|
||||||
|
logger.Fatal().Err(err).Msg("could not start self uninstall command")
|
||||||
|
}
|
||||||
|
cmd.Stdout = os.Stdout
|
||||||
|
cmd.Stderr = os.Stderr
|
||||||
|
logger.Warn().Msgf("service was uninstalled because device %q does not exist", cdUID)
|
||||||
|
_ = cmd.Wait()
|
||||||
|
os.Exit(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func selfUninstallLinux(p *prog, logger zerolog.Logger) {
|
||||||
|
if uninstallInvalidCdUID(p, logger, true) {
|
||||||
|
logger.Warn().Msgf("service was uninstalled because device %q does not exist", cdUID)
|
||||||
|
os.Exit(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
12
cmd/cli/self_upgrade_others.go
Normal file
12
cmd/cli/self_upgrade_others.go
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
//go:build !windows
|
||||||
|
|
||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"syscall"
|
||||||
|
)
|
||||||
|
|
||||||
|
// sysProcAttrForDetachedChildProcess returns *syscall.SysProcAttr instance for running a detached child command.
|
||||||
|
func sysProcAttrForDetachedChildProcess() *syscall.SysProcAttr {
|
||||||
|
return &syscall.SysProcAttr{Setsid: true}
|
||||||
|
}
|
||||||
18
cmd/cli/self_upgrade_windows.go
Normal file
18
cmd/cli/self_upgrade_windows.go
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"syscall"
|
||||||
|
)
|
||||||
|
|
||||||
|
// From: https://learn.microsoft.com/en-us/windows/win32/procthread/process-creation-flags?redirectedfrom=MSDN
|
||||||
|
|
||||||
|
// SYSCALL_CREATE_NO_WINDOW set flag to run process without a console window.
|
||||||
|
const SYSCALL_CREATE_NO_WINDOW = 0x08000000
|
||||||
|
|
||||||
|
// sysProcAttrForDetachedChildProcess returns *syscall.SysProcAttr instance for running self-upgrade command.
|
||||||
|
func sysProcAttrForDetachedChildProcess() *syscall.SysProcAttr {
|
||||||
|
return &syscall.SysProcAttr{
|
||||||
|
CreationFlags: syscall.CREATE_NEW_PROCESS_GROUP | SYSCALL_CREATE_NO_WINDOW,
|
||||||
|
HideWindow: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
24
cmd/cli/sema.go
Normal file
24
cmd/cli/sema.go
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
package cli
|
||||||
|
|
||||||
|
type semaphore interface {
|
||||||
|
acquire()
|
||||||
|
release()
|
||||||
|
}
|
||||||
|
|
||||||
|
type noopSemaphore struct{}
|
||||||
|
|
||||||
|
func (n noopSemaphore) acquire() {}
|
||||||
|
|
||||||
|
func (n noopSemaphore) release() {}
|
||||||
|
|
||||||
|
type chanSemaphore struct {
|
||||||
|
ready chan struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *chanSemaphore) acquire() {
|
||||||
|
c.ready <- struct{}{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *chanSemaphore) release() {
|
||||||
|
<-c.ready
|
||||||
|
}
|
||||||
268
cmd/cli/service.go
Normal file
268
cmd/cli/service.go
Normal file
@@ -0,0 +1,268 @@
|
|||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"runtime"
|
||||||
|
|
||||||
|
"github.com/coreos/go-systemd/v22/unit"
|
||||||
|
"github.com/kardianos/service"
|
||||||
|
|
||||||
|
"github.com/Control-D-Inc/ctrld/internal/router"
|
||||||
|
"github.com/Control-D-Inc/ctrld/internal/router/openwrt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// newService wraps service.New call to return service.Service
|
||||||
|
// wrapper which is suitable for the current platform.
|
||||||
|
func newService(i service.Interface, c *service.Config) (service.Service, error) {
|
||||||
|
s, err := service.New(i, c)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case router.IsOldOpenwrt(), router.IsNetGearOrbi():
|
||||||
|
return &procd{sysV: &sysV{s}, svcConfig: c}, nil
|
||||||
|
case router.IsGLiNet():
|
||||||
|
return &sysV{s}, nil
|
||||||
|
case s.Platform() == "unix-systemv":
|
||||||
|
return &sysV{s}, nil
|
||||||
|
case s.Platform() == "linux-systemd":
|
||||||
|
return &systemd{s}, nil
|
||||||
|
case s.Platform() == "darwin-launchd":
|
||||||
|
return newLaunchd(s), nil
|
||||||
|
|
||||||
|
}
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// sysV wraps a service.Service, and provide start/stop/status command
|
||||||
|
// base on "/etc/init.d/<service_name>".
|
||||||
|
//
|
||||||
|
// Use this on system where "service" command is not available, like GL.iNET router.
|
||||||
|
type sysV struct {
|
||||||
|
service.Service
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *sysV) installed() bool {
|
||||||
|
fi, err := os.Stat("/etc/init.d/ctrld")
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
mode := fi.Mode()
|
||||||
|
return mode.IsRegular() && (mode&0111) != 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *sysV) Start() error {
|
||||||
|
if !s.installed() {
|
||||||
|
return service.ErrNotInstalled
|
||||||
|
}
|
||||||
|
_, err := exec.Command("/etc/init.d/ctrld", "start").CombinedOutput()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *sysV) Stop() error {
|
||||||
|
if !s.installed() {
|
||||||
|
return service.ErrNotInstalled
|
||||||
|
}
|
||||||
|
_, err := exec.Command("/etc/init.d/ctrld", "stop").CombinedOutput()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *sysV) Restart() error {
|
||||||
|
if !s.installed() {
|
||||||
|
return service.ErrNotInstalled
|
||||||
|
}
|
||||||
|
// We don't care about error returned by s.Stop,
|
||||||
|
// because the service may already be stopped.
|
||||||
|
_ = s.Stop()
|
||||||
|
return s.Start()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *sysV) Status() (service.Status, error) {
|
||||||
|
if !s.installed() {
|
||||||
|
return service.StatusUnknown, service.ErrNotInstalled
|
||||||
|
}
|
||||||
|
return unixSystemVServiceStatus()
|
||||||
|
}
|
||||||
|
|
||||||
|
// procd wraps a service.Service, and provide start/stop command
|
||||||
|
// base on "/etc/init.d/<service_name>", status command base on parsing "ps" command output.
|
||||||
|
//
|
||||||
|
// Use this on system where "/etc/init.d/<service_name> status" command is not available,
|
||||||
|
// like old GL.iNET Opal router.
|
||||||
|
type procd struct {
|
||||||
|
*sysV
|
||||||
|
svcConfig *service.Config
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *procd) Status() (service.Status, error) {
|
||||||
|
if !s.installed() {
|
||||||
|
return service.StatusUnknown, service.ErrNotInstalled
|
||||||
|
}
|
||||||
|
bin := s.svcConfig.Executable
|
||||||
|
if bin == "" {
|
||||||
|
exe, err := os.Executable()
|
||||||
|
if err != nil {
|
||||||
|
return service.StatusUnknown, nil
|
||||||
|
}
|
||||||
|
bin = exe
|
||||||
|
}
|
||||||
|
|
||||||
|
// Looking for something like "/sbin/ctrld run ".
|
||||||
|
shellCmd := fmt.Sprintf("ps | grep -q %q", bin+" [r]un ")
|
||||||
|
if err := exec.Command("sh", "-c", shellCmd).Run(); err != nil {
|
||||||
|
return service.StatusStopped, nil
|
||||||
|
}
|
||||||
|
return service.StatusRunning, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// systemd wraps a service.Service, and provide status command to
|
||||||
|
// report the status correctly.
|
||||||
|
type systemd struct {
|
||||||
|
service.Service
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *systemd) Status() (service.Status, error) {
|
||||||
|
out, _ := exec.Command("systemctl", "status", "ctrld").CombinedOutput()
|
||||||
|
if bytes.Contains(out, []byte("/FAILURE)")) {
|
||||||
|
return service.StatusStopped, nil
|
||||||
|
}
|
||||||
|
return s.Service.Status()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *systemd) Start() error {
|
||||||
|
const systemdUnitFile = "/etc/systemd/system/ctrld.service"
|
||||||
|
f, err := os.Open(systemdUnitFile)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
if opts, change := ensureSystemdKillMode(f); change {
|
||||||
|
mode := os.FileMode(0644)
|
||||||
|
buf, err := io.ReadAll(unit.Serialize(opts))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(systemdUnitFile, buf, mode); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if out, err := exec.Command("systemctl", "daemon-reload").CombinedOutput(); err != nil {
|
||||||
|
return fmt.Errorf("systemctl daemon-reload failed: %w\n%s", err, string(out))
|
||||||
|
}
|
||||||
|
mainLog.Load().Debug().Msg("set KillMode=process successfully")
|
||||||
|
}
|
||||||
|
return s.Service.Start()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ensureSystemdKillMode ensure systemd unit file is configured with KillMode=process.
|
||||||
|
// This is necessary for running self-upgrade flow.
|
||||||
|
func ensureSystemdKillMode(r io.Reader) (opts []*unit.UnitOption, change bool) {
|
||||||
|
opts, err := unit.DeserializeOptions(r)
|
||||||
|
if err != nil {
|
||||||
|
mainLog.Load().Error().Err(err).Msg("failed to deserialize options")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
change = true
|
||||||
|
needKillModeOpt := true
|
||||||
|
killModeOpt := unit.NewUnitOption("Service", "KillMode", "process")
|
||||||
|
for _, opt := range opts {
|
||||||
|
if opt.Match(killModeOpt) {
|
||||||
|
needKillModeOpt = false
|
||||||
|
change = false
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if opt.Section == killModeOpt.Section && opt.Name == killModeOpt.Name {
|
||||||
|
opt.Value = killModeOpt.Value
|
||||||
|
needKillModeOpt = false
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if needKillModeOpt {
|
||||||
|
opts = append(opts, killModeOpt)
|
||||||
|
}
|
||||||
|
return opts, change
|
||||||
|
}
|
||||||
|
|
||||||
|
func newLaunchd(s service.Service) *launchd {
|
||||||
|
return &launchd{
|
||||||
|
Service: s,
|
||||||
|
statusErrMsg: "Permission denied",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// launchd wraps a service.Service, and provide status command to
|
||||||
|
// report the status correctly when not running as root on Darwin.
|
||||||
|
//
|
||||||
|
// TODO: remove this wrapper once https://github.com/kardianos/service/issues/400 fixed.
|
||||||
|
type launchd struct {
|
||||||
|
service.Service
|
||||||
|
statusErrMsg string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *launchd) Status() (service.Status, error) {
|
||||||
|
if os.Geteuid() != 0 {
|
||||||
|
return service.StatusUnknown, errors.New(l.statusErrMsg)
|
||||||
|
}
|
||||||
|
return l.Service.Status()
|
||||||
|
}
|
||||||
|
|
||||||
|
type task struct {
|
||||||
|
f func() error
|
||||||
|
abortOnError bool
|
||||||
|
Name string
|
||||||
|
}
|
||||||
|
|
||||||
|
func doTasks(tasks []task) bool {
|
||||||
|
for _, task := range tasks {
|
||||||
|
mainLog.Load().Debug().Msgf("Running task %s", task.Name)
|
||||||
|
if err := task.f(); err != nil {
|
||||||
|
if task.abortOnError {
|
||||||
|
mainLog.Load().Error().Msgf("error running task %s: %v", task.Name, err)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
// if this is darwin stop command, dont print debug
|
||||||
|
// since launchctl complains on every start
|
||||||
|
if runtime.GOOS != "darwin" || task.Name != "Stop" {
|
||||||
|
mainLog.Load().Debug().Msgf("error running task %s: %v", task.Name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkHasElevatedPrivilege() {
|
||||||
|
ok, err := hasElevatedPrivilege()
|
||||||
|
if err != nil {
|
||||||
|
mainLog.Load().Error().Msgf("could not detect user privilege: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
mainLog.Load().Error().Msg("Please relaunch process with admin/root privilege.")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func unixSystemVServiceStatus() (service.Status, error) {
|
||||||
|
out, err := exec.Command("/etc/init.d/ctrld", "status").CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
// Specific case for openwrt >= 24.10, it returns non-success code
|
||||||
|
// for above status command, which may not right.
|
||||||
|
if router.Name() == openwrt.Name {
|
||||||
|
if string(bytes.ToLower(bytes.TrimSpace(out))) == "inactive" {
|
||||||
|
return service.StatusStopped, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return service.StatusUnknown, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
switch string(bytes.ToLower(bytes.TrimSpace(out))) {
|
||||||
|
case "running":
|
||||||
|
return service.StatusRunning, nil
|
||||||
|
default:
|
||||||
|
return service.StatusStopped, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
22
cmd/cli/service_others.go
Normal file
22
cmd/cli/service_others.go
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
//go:build !windows
|
||||||
|
|
||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
func hasElevatedPrivilege() (bool, error) {
|
||||||
|
return os.Geteuid() == 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func openLogFile(path string, flags int) (*os.File, error) {
|
||||||
|
return os.OpenFile(path, flags, os.FileMode(0o600))
|
||||||
|
}
|
||||||
|
|
||||||
|
// hasLocalDnsServerRunning reports whether we are on Windows and having Dns server running.
|
||||||
|
func hasLocalDnsServerRunning() bool { return false }
|
||||||
|
|
||||||
|
func ConfigureWindowsServiceFailureActions(serviceName string) error { return nil }
|
||||||
|
|
||||||
|
func isRunningOnDomainControllerWindows() (bool, int) { return false, 0 }
|
||||||
28
cmd/cli/service_test.go
Normal file
28
cmd/cli/service_test.go
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Test_ensureSystemdKillMode(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
unitFile string
|
||||||
|
wantChange bool
|
||||||
|
}{
|
||||||
|
{"no KillMode", "[Service]\nExecStart=/bin/sleep 1", true},
|
||||||
|
{"not KillMode=process", "[Service]\nExecStart=/bin/sleep 1\nKillMode=mixed", true},
|
||||||
|
{"KillMode=process", "[Service]\nExecStart=/bin/sleep 1\nKillMode=process", false},
|
||||||
|
{"invalid unit file", "[Service\nExecStart=/bin/sleep 1\nKillMode=process", false},
|
||||||
|
}
|
||||||
|
for _, tc := range tests {
|
||||||
|
tc := tc
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
if _, change := ensureSystemdKillMode(strings.NewReader(tc.unitFile)); tc.wantChange != change {
|
||||||
|
t.Errorf("ensureSystemdKillMode(%q) = %v, want %v", tc.unitFile, change, tc.wantChange)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
227
cmd/cli/service_windows.go
Normal file
227
cmd/cli/service_windows.go
Normal file
@@ -0,0 +1,227 @@
|
|||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"reflect"
|
||||||
|
"runtime"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
"unsafe"
|
||||||
|
|
||||||
|
"github.com/microsoft/wmi/pkg/base/host"
|
||||||
|
"github.com/microsoft/wmi/pkg/base/instance"
|
||||||
|
"github.com/microsoft/wmi/pkg/base/query"
|
||||||
|
"github.com/microsoft/wmi/pkg/constant"
|
||||||
|
"golang.org/x/sys/windows"
|
||||||
|
"golang.org/x/sys/windows/svc/mgr"
|
||||||
|
)
|
||||||
|
|
||||||
|
func hasElevatedPrivilege() (bool, error) {
|
||||||
|
var sid *windows.SID
|
||||||
|
if err := windows.AllocateAndInitializeSid(
|
||||||
|
&windows.SECURITY_NT_AUTHORITY,
|
||||||
|
2,
|
||||||
|
windows.SECURITY_BUILTIN_DOMAIN_RID,
|
||||||
|
windows.DOMAIN_ALIAS_RID_ADMINS,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
&sid,
|
||||||
|
); err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
token := windows.Token(0)
|
||||||
|
return token.IsMember(sid)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConfigureWindowsServiceFailureActions checks if the given service
|
||||||
|
// has the correct failure actions configured, and updates them if not.
|
||||||
|
func ConfigureWindowsServiceFailureActions(serviceName string) error {
|
||||||
|
if runtime.GOOS != "windows" {
|
||||||
|
return nil // no-op on non-Windows
|
||||||
|
}
|
||||||
|
|
||||||
|
m, err := mgr.Connect()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer m.Disconnect()
|
||||||
|
|
||||||
|
s, err := m.OpenService(serviceName)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer s.Close()
|
||||||
|
|
||||||
|
// 1. Retrieve the current config
|
||||||
|
cfg, err := s.Config()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Update the Description
|
||||||
|
cfg.Description = "A highly configurable, multi-protocol DNS forwarding proxy"
|
||||||
|
|
||||||
|
// 3. Apply the updated config
|
||||||
|
if err := s.UpdateConfig(cfg); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Then proceed with existing actions, e.g. setting failure actions
|
||||||
|
actions := []mgr.RecoveryAction{
|
||||||
|
{Type: mgr.ServiceRestart, Delay: time.Second * 5}, // 5 seconds
|
||||||
|
{Type: mgr.ServiceRestart, Delay: time.Second * 5}, // 5 seconds
|
||||||
|
{Type: mgr.ServiceRestart, Delay: time.Second * 5}, // 5 seconds
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set the recovery actions (3 restarts, reset period = 120).
|
||||||
|
err = s.SetRecoveryActions(actions, 120)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure that failure actions are NOT triggered on user-initiated stops.
|
||||||
|
var failureActionsFlag windows.SERVICE_FAILURE_ACTIONS_FLAG
|
||||||
|
failureActionsFlag.FailureActionsOnNonCrashFailures = 0
|
||||||
|
|
||||||
|
if err := windows.ChangeServiceConfig2(
|
||||||
|
s.Handle,
|
||||||
|
windows.SERVICE_CONFIG_FAILURE_ACTIONS_FLAG,
|
||||||
|
(*byte)(unsafe.Pointer(&failureActionsFlag)),
|
||||||
|
); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func openLogFile(path string, mode int) (*os.File, error) {
|
||||||
|
if len(path) == 0 {
|
||||||
|
return nil, &os.PathError{Path: path, Op: "open", Err: syscall.ERROR_FILE_NOT_FOUND}
|
||||||
|
}
|
||||||
|
|
||||||
|
pathP, err := syscall.UTF16PtrFromString(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var access uint32
|
||||||
|
switch mode & (os.O_RDONLY | os.O_WRONLY | os.O_RDWR) {
|
||||||
|
case os.O_RDONLY:
|
||||||
|
access = windows.GENERIC_READ
|
||||||
|
case os.O_WRONLY:
|
||||||
|
access = windows.GENERIC_WRITE
|
||||||
|
case os.O_RDWR:
|
||||||
|
access = windows.GENERIC_READ | windows.GENERIC_WRITE
|
||||||
|
}
|
||||||
|
if mode&os.O_CREATE != 0 {
|
||||||
|
access |= windows.GENERIC_WRITE
|
||||||
|
}
|
||||||
|
if mode&os.O_APPEND != 0 {
|
||||||
|
access &^= windows.GENERIC_WRITE
|
||||||
|
access |= windows.FILE_APPEND_DATA
|
||||||
|
}
|
||||||
|
|
||||||
|
shareMode := uint32(syscall.FILE_SHARE_READ | syscall.FILE_SHARE_WRITE | syscall.FILE_SHARE_DELETE)
|
||||||
|
|
||||||
|
var sa *syscall.SecurityAttributes
|
||||||
|
|
||||||
|
var createMode uint32
|
||||||
|
switch {
|
||||||
|
case mode&(os.O_CREATE|os.O_EXCL) == (os.O_CREATE | os.O_EXCL):
|
||||||
|
createMode = windows.CREATE_NEW
|
||||||
|
case mode&(os.O_CREATE|os.O_TRUNC) == (os.O_CREATE | os.O_TRUNC):
|
||||||
|
createMode = windows.CREATE_ALWAYS
|
||||||
|
case mode&os.O_CREATE == os.O_CREATE:
|
||||||
|
createMode = windows.OPEN_ALWAYS
|
||||||
|
case mode&os.O_TRUNC == os.O_TRUNC:
|
||||||
|
createMode = windows.TRUNCATE_EXISTING
|
||||||
|
default:
|
||||||
|
createMode = windows.OPEN_EXISTING
|
||||||
|
}
|
||||||
|
|
||||||
|
handle, err := syscall.CreateFile(pathP, access, shareMode, sa, createMode, syscall.FILE_ATTRIBUTE_NORMAL, 0)
|
||||||
|
if err != nil {
|
||||||
|
return nil, &os.PathError{Path: path, Op: "open", Err: err}
|
||||||
|
}
|
||||||
|
|
||||||
|
return os.NewFile(uintptr(handle), path), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const processEntrySize = uint32(unsafe.Sizeof(windows.ProcessEntry32{}))
|
||||||
|
|
||||||
|
// hasLocalDnsServerRunning reports whether we are on Windows and having Dns server running.
|
||||||
|
func hasLocalDnsServerRunning() bool {
|
||||||
|
h, e := windows.CreateToolhelp32Snapshot(windows.TH32CS_SNAPPROCESS, 0)
|
||||||
|
if e != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
p := windows.ProcessEntry32{Size: processEntrySize}
|
||||||
|
for {
|
||||||
|
e := windows.Process32Next(h, &p)
|
||||||
|
if e != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if strings.ToLower(windows.UTF16ToString(p.ExeFile[:])) == "dns.exe" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func isRunningOnDomainControllerWindows() (bool, int) {
|
||||||
|
whost := host.NewWmiLocalHost()
|
||||||
|
q := query.NewWmiQuery("Win32_ComputerSystem")
|
||||||
|
instances, err := instance.GetWmiInstancesFromHost(whost, string(constant.CimV2), q)
|
||||||
|
if err != nil {
|
||||||
|
mainLog.Load().Debug().Err(err).Msg("WMI query failed")
|
||||||
|
return false, 0
|
||||||
|
}
|
||||||
|
if instances == nil {
|
||||||
|
mainLog.Load().Debug().Msg("WMI query returned nil instances")
|
||||||
|
return false, 0
|
||||||
|
}
|
||||||
|
defer instances.Close()
|
||||||
|
|
||||||
|
if len(instances) == 0 {
|
||||||
|
mainLog.Load().Debug().Msg("no rows returned from Win32_ComputerSystem")
|
||||||
|
return false, 0
|
||||||
|
}
|
||||||
|
|
||||||
|
val, err := instances[0].GetProperty("DomainRole")
|
||||||
|
if err != nil {
|
||||||
|
mainLog.Load().Debug().Err(err).Msg("failed to get DomainRole property")
|
||||||
|
return false, 0
|
||||||
|
}
|
||||||
|
if val == nil {
|
||||||
|
mainLog.Load().Debug().Msg("DomainRole property is nil")
|
||||||
|
return false, 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Safely handle varied types: string or integer
|
||||||
|
var roleInt int
|
||||||
|
switch v := val.(type) {
|
||||||
|
case string:
|
||||||
|
// "4", "5", etc.
|
||||||
|
parsed, parseErr := strconv.Atoi(v)
|
||||||
|
if parseErr != nil {
|
||||||
|
mainLog.Load().Debug().Err(parseErr).Msgf("failed to parse DomainRole value %q", v)
|
||||||
|
return false, 0
|
||||||
|
}
|
||||||
|
roleInt = parsed
|
||||||
|
case int8, int16, int32, int64:
|
||||||
|
roleInt = int(reflect.ValueOf(v).Int())
|
||||||
|
case uint8, uint16, uint32, uint64:
|
||||||
|
roleInt = int(reflect.ValueOf(v).Uint())
|
||||||
|
default:
|
||||||
|
mainLog.Load().Debug().Msgf("unexpected DomainRole type: %T value=%v", v, v)
|
||||||
|
return false, 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if role indicates a domain controller
|
||||||
|
isDC := roleInt == BackupDomainController || roleInt == PrimaryDomainController
|
||||||
|
return isDC, roleInt
|
||||||
|
}
|
||||||
25
cmd/cli/service_windows_test.go
Normal file
25
cmd/cli/service_windows_test.go
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Test_hasLocalDnsServerRunning(t *testing.T) {
|
||||||
|
start := time.Now()
|
||||||
|
hasDns := hasLocalDnsServerRunning()
|
||||||
|
t.Logf("Using Windows API takes: %d", time.Since(start).Milliseconds())
|
||||||
|
|
||||||
|
start = time.Now()
|
||||||
|
hasDnsPowershell := hasLocalDnsServerRunningPowershell()
|
||||||
|
t.Logf("Using Powershell takes: %d", time.Since(start).Milliseconds())
|
||||||
|
|
||||||
|
if hasDns != hasDnsPowershell {
|
||||||
|
t.Fatalf("result mismatch, want: %v, got: %v", hasDnsPowershell, hasDns)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasLocalDnsServerRunningPowershell() bool {
|
||||||
|
_, err := powershell("Get-Process -Name DNS")
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
126
cmd/cli/upstream_monitor.go
Normal file
126
cmd/cli/upstream_monitor.go
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/Control-D-Inc/ctrld"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// maxFailureRequest is the maximum failed queries allowed before an upstream is marked as down.
|
||||||
|
maxFailureRequest = 50
|
||||||
|
// checkUpstreamBackoffSleep is the time interval between each upstream checks.
|
||||||
|
checkUpstreamBackoffSleep = 2 * time.Second
|
||||||
|
)
|
||||||
|
|
||||||
|
// upstreamMonitor performs monitoring upstreams health.
|
||||||
|
type upstreamMonitor struct {
|
||||||
|
cfg *ctrld.Config
|
||||||
|
|
||||||
|
mu sync.RWMutex
|
||||||
|
checking map[string]bool
|
||||||
|
down map[string]bool
|
||||||
|
failureReq map[string]uint64
|
||||||
|
recovered map[string]bool
|
||||||
|
|
||||||
|
// failureTimerActive tracks if a timer is already running for a given upstream.
|
||||||
|
failureTimerActive map[string]bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func newUpstreamMonitor(cfg *ctrld.Config) *upstreamMonitor {
|
||||||
|
um := &upstreamMonitor{
|
||||||
|
cfg: cfg,
|
||||||
|
checking: make(map[string]bool),
|
||||||
|
down: make(map[string]bool),
|
||||||
|
failureReq: make(map[string]uint64),
|
||||||
|
recovered: make(map[string]bool),
|
||||||
|
failureTimerActive: make(map[string]bool),
|
||||||
|
}
|
||||||
|
for n := range cfg.Upstream {
|
||||||
|
upstream := upstreamPrefix + n
|
||||||
|
um.reset(upstream)
|
||||||
|
}
|
||||||
|
um.reset(upstreamOS)
|
||||||
|
return um
|
||||||
|
}
|
||||||
|
|
||||||
|
// increaseFailureCount increases failed queries count for an upstream by 1 and logs debug information.
|
||||||
|
// It uses a timer to debounce failure detection, ensuring that an upstream is marked as down
|
||||||
|
// within 10 seconds if failures persist, without spawning duplicate goroutines.
|
||||||
|
func (um *upstreamMonitor) increaseFailureCount(upstream string) {
|
||||||
|
um.mu.Lock()
|
||||||
|
defer um.mu.Unlock()
|
||||||
|
|
||||||
|
if um.recovered[upstream] {
|
||||||
|
mainLog.Load().Debug().Msgf("upstream %q is recovered, skipping failure count increase", upstream)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
um.failureReq[upstream] += 1
|
||||||
|
failedCount := um.failureReq[upstream]
|
||||||
|
|
||||||
|
// Log the updated failure count.
|
||||||
|
mainLog.Load().Debug().Msgf("upstream %q failure count updated to %d", upstream, failedCount)
|
||||||
|
|
||||||
|
// If this is the first failure and no timer is running, start a 10-second timer.
|
||||||
|
if failedCount == 1 && !um.failureTimerActive[upstream] {
|
||||||
|
um.failureTimerActive[upstream] = true
|
||||||
|
go func(upstream string) {
|
||||||
|
time.Sleep(10 * time.Second)
|
||||||
|
um.mu.Lock()
|
||||||
|
defer um.mu.Unlock()
|
||||||
|
// If no success occurred during the 10-second window (i.e. counter remains > 0)
|
||||||
|
// and the upstream is not in a recovered state, mark it as down.
|
||||||
|
if um.failureReq[upstream] > 0 && !um.recovered[upstream] {
|
||||||
|
um.down[upstream] = true
|
||||||
|
mainLog.Load().Warn().Msgf("upstream %q marked as down after 10 seconds (failure count: %d)", upstream, um.failureReq[upstream])
|
||||||
|
}
|
||||||
|
// Reset the timer flag so that a new timer can be spawned if needed.
|
||||||
|
um.failureTimerActive[upstream] = false
|
||||||
|
}(upstream)
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the failure count quickly reaches the threshold, mark the upstream as down immediately.
|
||||||
|
if failedCount >= maxFailureRequest {
|
||||||
|
um.down[upstream] = true
|
||||||
|
mainLog.Load().Warn().Msgf("upstream %q marked as down immediately (failure count: %d)", upstream, failedCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// isDown reports whether the given upstream is being marked as down.
|
||||||
|
func (um *upstreamMonitor) isDown(upstream string) bool {
|
||||||
|
um.mu.Lock()
|
||||||
|
defer um.mu.Unlock()
|
||||||
|
|
||||||
|
return um.down[upstream]
|
||||||
|
}
|
||||||
|
|
||||||
|
// reset marks an upstream as up and set failed queries counter to zero.
|
||||||
|
func (um *upstreamMonitor) reset(upstream string) {
|
||||||
|
um.mu.Lock()
|
||||||
|
um.failureReq[upstream] = 0
|
||||||
|
um.down[upstream] = false
|
||||||
|
um.recovered[upstream] = true
|
||||||
|
um.mu.Unlock()
|
||||||
|
go func() {
|
||||||
|
// debounce the recovery to avoid incrementing failure counts already in flight
|
||||||
|
time.Sleep(1 * time.Second)
|
||||||
|
um.mu.Lock()
|
||||||
|
um.recovered[upstream] = false
|
||||||
|
um.mu.Unlock()
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
// countHealthy returns the number of upstreams in the provided map that are considered healthy.
|
||||||
|
func (um *upstreamMonitor) countHealthy(upstreams []string) int {
|
||||||
|
var count int
|
||||||
|
um.mu.RLock()
|
||||||
|
for _, upstream := range upstreams {
|
||||||
|
if !um.down[upstream] {
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
um.mu.RUnlock()
|
||||||
|
return count
|
||||||
|
}
|
||||||
20
cmd/cli/winres/winres.json
Normal file
20
cmd/cli/winres/winres.json
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"RT_VERSION": {
|
||||||
|
"#1": {
|
||||||
|
"0000": {
|
||||||
|
"fixed": {
|
||||||
|
"file_version": "0.0.0.1"
|
||||||
|
},
|
||||||
|
"info": {
|
||||||
|
"0409": {
|
||||||
|
"CompanyName": "ControlD Inc",
|
||||||
|
"FileDescription": "Control D DNS daemon",
|
||||||
|
"ProductName": "ctrld",
|
||||||
|
"InternalName": "ctrld",
|
||||||
|
"LegalCopyright": "ControlD Inc 2024"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
4
cmd/cli/winres_windows.go
Normal file
4
cmd/cli/winres_windows.go
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
//go:generate go-winres make --product-version=git-tag --file-version=git-tag
|
||||||
|
package cli
|
||||||
|
|
||||||
|
// Placeholder file for windows builds.
|
||||||
116
cmd/ctrld/cli.go
116
cmd/ctrld/cli.go
@@ -1,116 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"log"
|
|
||||||
"os"
|
|
||||||
"os/exec"
|
|
||||||
"runtime"
|
|
||||||
|
|
||||||
"github.com/kardianos/service"
|
|
||||||
"github.com/pelletier/go-toml"
|
|
||||||
"github.com/spf13/cobra"
|
|
||||||
"github.com/spf13/viper"
|
|
||||||
)
|
|
||||||
|
|
||||||
var (
|
|
||||||
v = viper.NewWithOptions(viper.KeyDelimiter("::"))
|
|
||||||
defaultConfigWritten = false
|
|
||||||
)
|
|
||||||
|
|
||||||
func initCLI() {
|
|
||||||
// Enable opening via explorer.exe on Windows.
|
|
||||||
// See: https://github.com/spf13/cobra/issues/844.
|
|
||||||
cobra.MousetrapHelpText = ""
|
|
||||||
|
|
||||||
rootCmd := &cobra.Command{
|
|
||||||
Use: "ctrld",
|
|
||||||
Short: "Running Control-D DNS proxy server",
|
|
||||||
Version: "1.0.0",
|
|
||||||
}
|
|
||||||
rootCmd.PersistentFlags().BoolVarP(&verbose, "verbose", "v", false, "verbose log output")
|
|
||||||
|
|
||||||
runCmd := &cobra.Command{
|
|
||||||
Use: "run",
|
|
||||||
Short: "Run the DNS proxy server",
|
|
||||||
Args: cobra.NoArgs,
|
|
||||||
Run: func(cmd *cobra.Command, args []string) {
|
|
||||||
if daemon && runtime.GOOS == "windows" {
|
|
||||||
log.Fatal("Cannot run in daemon mode. Please install a Windows service.")
|
|
||||||
}
|
|
||||||
if configPath != "" {
|
|
||||||
v.SetConfigFile(configPath)
|
|
||||||
}
|
|
||||||
if err := v.ReadInConfig(); err != nil {
|
|
||||||
if _, ok := err.(viper.ConfigFileNotFoundError); ok {
|
|
||||||
writeConfigFile()
|
|
||||||
defaultConfigWritten = true
|
|
||||||
} else {
|
|
||||||
log.Fatalf("failed to decode config file: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if err := v.Unmarshal(&cfg); err != nil {
|
|
||||||
log.Fatalf("failed to unmarshal config: %v", err)
|
|
||||||
}
|
|
||||||
initLogging()
|
|
||||||
if daemon {
|
|
||||||
exe, err := os.Executable()
|
|
||||||
if err != nil {
|
|
||||||
mainLog.Error().Err(err).Msg("failed to find the binary")
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
curDir, err := os.Getwd()
|
|
||||||
if err != nil {
|
|
||||||
mainLog.Error().Err(err).Msg("failed to get current working directory")
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
// If running as daemon, re-run the command in background, with daemon off.
|
|
||||||
cmd := exec.Command(exe, append(os.Args[1:], "-d=false")...)
|
|
||||||
cmd.Dir = curDir
|
|
||||||
if err := cmd.Start(); err != nil {
|
|
||||||
mainLog.Error().Err(err).Msg("failed to start process as daemon")
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
mainLog.Info().Int("pid", cmd.Process.Pid).Msg("DNS proxy started")
|
|
||||||
os.Exit(0)
|
|
||||||
}
|
|
||||||
|
|
||||||
s, err := service.New(&prog{}, svcConfig)
|
|
||||||
if err != nil {
|
|
||||||
mainLog.Fatal().Err(err).Msg("failed create new service")
|
|
||||||
}
|
|
||||||
serviceLogger, err := s.Logger(nil)
|
|
||||||
if err != nil {
|
|
||||||
mainLog.Error().Err(err).Msg("failed to get service logger")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := s.Run(); err != nil {
|
|
||||||
if sErr := serviceLogger.Error(err); sErr != nil {
|
|
||||||
mainLog.Error().Err(sErr).Msg("failed to write service log")
|
|
||||||
}
|
|
||||||
mainLog.Error().Err(err).Msg("failed to start service")
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}
|
|
||||||
runCmd.Flags().BoolVarP(&daemon, "daemon", "d", false, "Run as daemon")
|
|
||||||
runCmd.Flags().StringVarP(&configPath, "config", "c", "", "Path to config file")
|
|
||||||
|
|
||||||
rootCmd.AddCommand(runCmd)
|
|
||||||
|
|
||||||
if err := rootCmd.Execute(); err != nil {
|
|
||||||
fmt.Println(err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func writeConfigFile() {
|
|
||||||
c := v.AllSettings()
|
|
||||||
bs, err := toml.Marshal(c)
|
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("unable to marshal config to toml: %v", err)
|
|
||||||
}
|
|
||||||
if err := os.WriteFile("config.toml", bs, 0600); err != nil {
|
|
||||||
log.Printf("failed to write config file: %v\n", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,215 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"crypto/rand"
|
|
||||||
"encoding/hex"
|
|
||||||
"fmt"
|
|
||||||
"net"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/miekg/dns"
|
|
||||||
|
|
||||||
"github.com/Control-D-Inc/ctrld"
|
|
||||||
)
|
|
||||||
|
|
||||||
func (p *prog) serveUDP(listenerNum string) error {
|
|
||||||
listenerConfig := p.cfg.Listener[listenerNum]
|
|
||||||
// make sure ip is allocated
|
|
||||||
if allocErr := p.allocateIP(listenerConfig.IP); allocErr != nil {
|
|
||||||
mainLog.Error().Err(allocErr).Str("ip", listenerConfig.IP).Msg("serveUDP: failed to allocate listen ip")
|
|
||||||
return allocErr
|
|
||||||
}
|
|
||||||
|
|
||||||
handler := dns.HandlerFunc(func(w dns.ResponseWriter, m *dns.Msg) {
|
|
||||||
domain := canonicalName(m.Question[0].Name)
|
|
||||||
reqId := requestID()
|
|
||||||
fmtSrcToDest := fmtRemoteToLocal(listenerNum, w.RemoteAddr().String(), w.LocalAddr().String())
|
|
||||||
t := time.Now()
|
|
||||||
ctx := context.WithValue(context.Background(), ctrld.ReqIdCtxKey{}, reqId)
|
|
||||||
ctrld.Log(ctx, proxyLog.Debug(), "%s received query: %s", fmtSrcToDest, domain)
|
|
||||||
upstreams, matched := p.upstreamFor(ctx, listenerNum, listenerConfig, w.RemoteAddr(), domain)
|
|
||||||
var answer *dns.Msg
|
|
||||||
if !matched && listenerConfig.Restricted {
|
|
||||||
answer = new(dns.Msg)
|
|
||||||
answer.SetRcode(m, dns.RcodeRefused)
|
|
||||||
|
|
||||||
} else {
|
|
||||||
answer = p.proxy(ctx, upstreams, m)
|
|
||||||
rtt := time.Since(t)
|
|
||||||
ctrld.Log(ctx, proxyLog.Debug(), "received response of %d bytes in %s", answer.Len(), rtt)
|
|
||||||
}
|
|
||||||
if err := w.WriteMsg(answer); err != nil {
|
|
||||||
ctrld.Log(ctx, mainLog.Error().Err(err), "serveUDP: failed to send DNS response to client")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
s := &dns.Server{
|
|
||||||
Addr: net.JoinHostPort(listenerConfig.IP, strconv.Itoa(listenerConfig.Port)),
|
|
||||||
Net: "udp",
|
|
||||||
Handler: handler,
|
|
||||||
}
|
|
||||||
return s.ListenAndServe()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *prog) upstreamFor(ctx context.Context, defaultUpstreamNum string, lc *ctrld.ListenerConfig, addr net.Addr, domain string) ([]string, bool) {
|
|
||||||
upstreams := []string{"upstream." + defaultUpstreamNum}
|
|
||||||
matchedPolicy := "no policy"
|
|
||||||
matchedNetwork := "no network"
|
|
||||||
matchedRule := "no rule"
|
|
||||||
matched := false
|
|
||||||
|
|
||||||
defer func() {
|
|
||||||
if !matched && lc.Restricted {
|
|
||||||
ctrld.Log(ctx, proxyLog.Info(), "query refused, %s does not match any network policy", addr.String())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
ctrld.Log(ctx, proxyLog.Info(), "%s, %s, %s -> %v", matchedPolicy, matchedNetwork, matchedRule, upstreams)
|
|
||||||
}()
|
|
||||||
|
|
||||||
if lc.Policy == nil {
|
|
||||||
return upstreams, false
|
|
||||||
}
|
|
||||||
|
|
||||||
do := func(policyUpstreams []string) {
|
|
||||||
upstreams = append([]string(nil), policyUpstreams...)
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, rule := range lc.Policy.Rules {
|
|
||||||
// There's only one entry per rule, config validation ensures this.
|
|
||||||
for source, targets := range rule {
|
|
||||||
if source == domain || wildcardMatches(source, domain) {
|
|
||||||
matchedPolicy = lc.Policy.Name
|
|
||||||
matchedRule = source
|
|
||||||
do(targets)
|
|
||||||
matched = true
|
|
||||||
return upstreams, matched
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var sourceIP net.IP
|
|
||||||
switch addr := addr.(type) {
|
|
||||||
case *net.UDPAddr:
|
|
||||||
sourceIP = addr.IP
|
|
||||||
case *net.TCPAddr:
|
|
||||||
sourceIP = addr.IP
|
|
||||||
}
|
|
||||||
for _, rule := range lc.Policy.Networks {
|
|
||||||
for source, targets := range rule {
|
|
||||||
networkNum := strings.TrimPrefix(source, "network.")
|
|
||||||
nc := p.cfg.Network[networkNum]
|
|
||||||
if nc == nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, ipNet := range nc.IPNets {
|
|
||||||
if ipNet.Contains(sourceIP) {
|
|
||||||
matchedPolicy = lc.Policy.Name
|
|
||||||
matchedNetwork = source
|
|
||||||
do(targets)
|
|
||||||
matched = true
|
|
||||||
return upstreams, matched
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return upstreams, matched
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *prog) proxy(ctx context.Context, upstreams []string, msg *dns.Msg) *dns.Msg {
|
|
||||||
upstreamConfigs := p.upstreamConfigsFromUpstreamNumbers(upstreams)
|
|
||||||
resolve := func(n int, upstreamConfig *ctrld.UpstreamConfig, msg *dns.Msg) *dns.Msg {
|
|
||||||
ctrld.Log(ctx, proxyLog.Debug(), "sending query to %s: %s", upstreams[n], upstreamConfig.Name)
|
|
||||||
dnsResolver, err := ctrld.NewResolver(upstreamConfig)
|
|
||||||
if err != nil {
|
|
||||||
ctrld.Log(ctx, proxyLog.Error().Err(err), "failed to create resolver")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if upstreamConfig.Timeout > 0 {
|
|
||||||
timeoutCtx, cancel := context.WithTimeout(ctx, time.Millisecond*time.Duration(upstreamConfig.Timeout))
|
|
||||||
defer cancel()
|
|
||||||
ctx = timeoutCtx
|
|
||||||
}
|
|
||||||
answer, err := dnsResolver.Resolve(ctx, msg)
|
|
||||||
if err != nil {
|
|
||||||
ctrld.Log(ctx, proxyLog.Error().Err(err), "failed to resolve query")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return answer
|
|
||||||
}
|
|
||||||
for n, upstreamConfig := range upstreamConfigs {
|
|
||||||
if answer := resolve(n, upstreamConfig, msg); answer != nil {
|
|
||||||
return answer
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ctrld.Log(ctx, proxyLog.Error(), "all upstreams failed")
|
|
||||||
answer := new(dns.Msg)
|
|
||||||
answer.SetRcode(msg, dns.RcodeServerFailure)
|
|
||||||
return answer
|
|
||||||
}
|
|
||||||
|
|
||||||
// canonicalName returns canonical name from FQDN with "." trimmed.
|
|
||||||
func canonicalName(fqdn string) string {
|
|
||||||
q := strings.TrimSpace(fqdn)
|
|
||||||
q = strings.TrimSuffix(q, ".")
|
|
||||||
// https://datatracker.ietf.org/doc/html/rfc4343
|
|
||||||
q = strings.ToLower(q)
|
|
||||||
|
|
||||||
return q
|
|
||||||
}
|
|
||||||
|
|
||||||
func wildcardMatches(wildcard, domain string) bool {
|
|
||||||
// Wildcard match.
|
|
||||||
wildCardParts := strings.Split(wildcard, "*")
|
|
||||||
if len(wildCardParts) != 2 {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
switch {
|
|
||||||
case len(wildCardParts[0]) > 0 && len(wildCardParts[1]) > 0:
|
|
||||||
// Domain must match both prefix and suffix.
|
|
||||||
return strings.HasPrefix(domain, wildCardParts[0]) && strings.HasSuffix(domain, wildCardParts[1])
|
|
||||||
|
|
||||||
case len(wildCardParts[1]) > 0:
|
|
||||||
// Only suffix must match.
|
|
||||||
return strings.HasSuffix(domain, wildCardParts[1])
|
|
||||||
|
|
||||||
case len(wildCardParts[0]) > 0:
|
|
||||||
// Only prefix must match.
|
|
||||||
return strings.HasPrefix(domain, wildCardParts[0])
|
|
||||||
}
|
|
||||||
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
func fmtRemoteToLocal(listenerNum, remote, local string) string {
|
|
||||||
return fmt.Sprintf("%s -> listener.%s: %s:", remote, listenerNum, local)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *prog) upstreamConfigsFromUpstreamNumbers(upstreams []string) []*ctrld.UpstreamConfig {
|
|
||||||
upstreamConfigs := make([]*ctrld.UpstreamConfig, 0, len(upstreams))
|
|
||||||
for _, upstream := range upstreams {
|
|
||||||
upstreamNum := strings.TrimPrefix(upstream, "upstream.")
|
|
||||||
upstreamConfigs = append(upstreamConfigs, p.cfg.Upstream[upstreamNum])
|
|
||||||
}
|
|
||||||
if len(upstreamConfigs) == 0 {
|
|
||||||
upstreamConfigs = []*ctrld.UpstreamConfig{osUpstreamConfig}
|
|
||||||
}
|
|
||||||
return upstreamConfigs
|
|
||||||
}
|
|
||||||
|
|
||||||
func requestID() string {
|
|
||||||
b := make([]byte, 3) // 6 chars
|
|
||||||
if _, err := rand.Read(b); err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
return hex.EncodeToString(b)
|
|
||||||
}
|
|
||||||
|
|
||||||
var osUpstreamConfig = &ctrld.UpstreamConfig{
|
|
||||||
Name: "OS resolver",
|
|
||||||
Type: "os",
|
|
||||||
}
|
|
||||||
@@ -1,117 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"net"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
|
|
||||||
"github.com/Control-D-Inc/ctrld"
|
|
||||||
"github.com/Control-D-Inc/ctrld/testhelper"
|
|
||||||
)
|
|
||||||
|
|
||||||
func Test_wildcardMatches(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
wildcard string
|
|
||||||
domain string
|
|
||||||
match bool
|
|
||||||
}{
|
|
||||||
{"prefix parent should not match", "*.windscribe.com", "windscribe.com", false},
|
|
||||||
{"prefix", "*.windscribe.com", "anything.windscribe.com", true},
|
|
||||||
{"prefix not match other domain", "*.windscribe.com", "example.com", false},
|
|
||||||
{"prefix not match domain in name", "*.windscribe.com", "wwindscribe.com", false},
|
|
||||||
{"suffix", "suffix.*", "suffix.windscribe.com", true},
|
|
||||||
{"suffix not match other", "suffix.*", "suffix1.windscribe.com", false},
|
|
||||||
{"both", "suffix.*.windscribe.com", "suffix.anything.windscribe.com", true},
|
|
||||||
{"both not match", "suffix.*.windscribe.com", "suffix1.suffix.windscribe.com", false},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tc := range tests {
|
|
||||||
tc := tc
|
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
if got := wildcardMatches(tc.wildcard, tc.domain); got != tc.match {
|
|
||||||
t.Errorf("unexpected result, wildcard: %s, domain: %s, want: %v, got: %v", tc.wildcard, tc.domain, tc.match, got)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func Test_canonicalName(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
domain string
|
|
||||||
canonical string
|
|
||||||
}{
|
|
||||||
{"fqdn to canonical", "windscribe.com.", "windscribe.com"},
|
|
||||||
{"already canonical", "windscribe.com", "windscribe.com"},
|
|
||||||
{"case insensitive", "Windscribe.Com.", "windscribe.com"},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tc := range tests {
|
|
||||||
tc := tc
|
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
if got := canonicalName(tc.domain); got != tc.canonical {
|
|
||||||
t.Errorf("unexpected result, want: %s, got: %s", tc.canonical, got)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func Test_prog_upstreamFor(t *testing.T) {
|
|
||||||
cfg := testhelper.SampleConfig(t)
|
|
||||||
prog := &prog{cfg: cfg}
|
|
||||||
for _, nc := range prog.cfg.Network {
|
|
||||||
for _, cidr := range nc.Cidrs {
|
|
||||||
_, ipNet, err := net.ParseCIDR(cidr)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
nc.IPNets = append(nc.IPNets, ipNet)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
ip string
|
|
||||||
defaultUpstreamNum string
|
|
||||||
lc *ctrld.ListenerConfig
|
|
||||||
domain string
|
|
||||||
upstreams []string
|
|
||||||
matched bool
|
|
||||||
}{
|
|
||||||
{"Policy map matches", "192.168.0.1:0", "0", prog.cfg.Listener["0"], "abc.xyz", []string{"upstream.1", "upstream.0"}, true},
|
|
||||||
{"Policy split matches", "192.168.0.1:0", "0", prog.cfg.Listener["0"], "abc.ru", []string{"upstream.1"}, true},
|
|
||||||
{"Policy map for other network matches", "192.168.1.2:0", "0", prog.cfg.Listener["0"], "abc.xyz", []string{"upstream.0"}, true},
|
|
||||||
{"No policy map for listener", "192.168.1.2:0", "1", prog.cfg.Listener["1"], "abc.ru", []string{"upstream.1"}, false},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tc := range tests {
|
|
||||||
tc := tc
|
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
for _, network := range []string{"udp", "tcp"} {
|
|
||||||
var (
|
|
||||||
addr net.Addr
|
|
||||||
err error
|
|
||||||
)
|
|
||||||
switch network {
|
|
||||||
case "udp":
|
|
||||||
addr, err = net.ResolveUDPAddr(network, tc.ip)
|
|
||||||
case "tcp":
|
|
||||||
addr, err = net.ResolveTCPAddr(network, tc.ip)
|
|
||||||
}
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.NotNil(t, addr)
|
|
||||||
ctx := context.WithValue(context.Background(), ctrld.ReqIdCtxKey{}, requestID())
|
|
||||||
upstreams, matched := prog.upstreamFor(ctx, tc.defaultUpstreamNum, tc.lc, addr, tc.domain)
|
|
||||||
assert.Equal(t, tc.matched, matched)
|
|
||||||
assert.Equal(t, tc.upstreams, upstreams)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,65 +1,13 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"os"
|
"os"
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/rs/zerolog"
|
"github.com/Control-D-Inc/ctrld/cmd/cli"
|
||||||
|
|
||||||
"github.com/Control-D-Inc/ctrld"
|
|
||||||
)
|
|
||||||
|
|
||||||
var (
|
|
||||||
configPath string
|
|
||||||
daemon bool
|
|
||||||
cfg ctrld.Config
|
|
||||||
verbose bool
|
|
||||||
|
|
||||||
bootstrapDNS = "76.76.2.0"
|
|
||||||
|
|
||||||
rootLogger = zerolog.New(io.Discard)
|
|
||||||
mainLog = rootLogger
|
|
||||||
proxyLog = rootLogger
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
ctrld.InitConfig(v, "config")
|
cli.Main()
|
||||||
initCLI()
|
// make sure we exit with 0 if there are no errors
|
||||||
}
|
os.Exit(0)
|
||||||
|
|
||||||
func initLogging() {
|
|
||||||
writers := []io.Writer{io.Discard}
|
|
||||||
isLog := cfg.Service.LogLevel != ""
|
|
||||||
if logPath := cfg.Service.LogPath; logPath != "" {
|
|
||||||
logFile, err := os.OpenFile(logPath, os.O_APPEND|os.O_CREATE|os.O_RDWR, 0600)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "failed to creating log file: %v", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
isLog = true
|
|
||||||
writers = append(writers, logFile)
|
|
||||||
}
|
|
||||||
zerolog.TimeFieldFormat = zerolog.TimeFormatUnixMs
|
|
||||||
if verbose || isLog {
|
|
||||||
consoleWriter := zerolog.NewConsoleWriter(func(w *zerolog.ConsoleWriter) {
|
|
||||||
w.TimeFormat = time.StampMilli
|
|
||||||
})
|
|
||||||
writers = append(writers, consoleWriter)
|
|
||||||
multi := zerolog.MultiLevelWriter(writers...)
|
|
||||||
mainLog = mainLog.Output(multi).With().Timestamp().Str("prefix", "main").Logger()
|
|
||||||
proxyLog = proxyLog.Output(multi).With().Timestamp().Logger()
|
|
||||||
// TODO: find a better way.
|
|
||||||
ctrld.ProxyLog = proxyLog
|
|
||||||
}
|
|
||||||
if cfg.Service.LogLevel == "" {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
level, err := zerolog.ParseLevel(cfg.Service.LogLevel)
|
|
||||||
if err != nil {
|
|
||||||
mainLog.Warn().Err(err).Msg("could not set log level")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
zerolog.SetGlobalLevel(level)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,25 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"os/exec"
|
|
||||||
)
|
|
||||||
|
|
||||||
// allocate loopback ip
|
|
||||||
// sudo ip a add 127.0.0.2/24 dev lo
|
|
||||||
func allocateIP(ip string) error {
|
|
||||||
cmd := exec.Command("ip", "a", "add", ip+"/24", "dev", "lo")
|
|
||||||
if err := cmd.Run(); err != nil {
|
|
||||||
mainLog.Error().Err(err).Msg("allocateIP failed")
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func deAllocateIP(ip string) error {
|
|
||||||
cmd := exec.Command("ip", "a", "del", ip+"/24", "dev", "lo")
|
|
||||||
if err := cmd.Run(); err != nil {
|
|
||||||
mainLog.Error().Err(err).Msg("deAllocateIP failed")
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
//go:build darwin
|
|
||||||
// +build darwin
|
|
||||||
|
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"os/exec"
|
|
||||||
)
|
|
||||||
|
|
||||||
// allocate loopback ip
|
|
||||||
// sudo ifconfig lo0 alias 127.0.0.2 up
|
|
||||||
func allocateIP(ip string) error {
|
|
||||||
cmd := exec.Command("ifconfig", "lo0", "alias", ip, "up")
|
|
||||||
if err := cmd.Run(); err != nil {
|
|
||||||
mainLog.Error().Err(err).Msg("allocateIP failed")
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func deAllocateIP(ip string) error {
|
|
||||||
cmd := exec.Command("ifconfig", "lo0", "-alias", ip)
|
|
||||||
if err := cmd.Run(); err != nil {
|
|
||||||
mainLog.Error().Err(err).Msg("deAllocateIP failed")
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@@ -1,159 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"net"
|
|
||||||
"os"
|
|
||||||
"strconv"
|
|
||||||
"sync"
|
|
||||||
"syscall"
|
|
||||||
|
|
||||||
"github.com/kardianos/service"
|
|
||||||
"github.com/miekg/dns"
|
|
||||||
|
|
||||||
"github.com/Control-D-Inc/ctrld"
|
|
||||||
)
|
|
||||||
|
|
||||||
var errWindowsAddrInUse = syscall.Errno(0x2740)
|
|
||||||
|
|
||||||
var svcConfig = &service.Config{
|
|
||||||
Name: "ctrld",
|
|
||||||
DisplayName: "Control-D Helper Service",
|
|
||||||
}
|
|
||||||
|
|
||||||
type prog struct {
|
|
||||||
cfg *ctrld.Config
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *prog) Start(s service.Service) error {
|
|
||||||
p.cfg = &cfg
|
|
||||||
go p.run()
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *prog) run() {
|
|
||||||
var wg sync.WaitGroup
|
|
||||||
wg.Add(len(p.cfg.Listener))
|
|
||||||
|
|
||||||
for _, nc := range p.cfg.Network {
|
|
||||||
for _, cidr := range nc.Cidrs {
|
|
||||||
_, ipNet, err := net.ParseCIDR(cidr)
|
|
||||||
if err != nil {
|
|
||||||
proxyLog.Error().Err(err).Str("network", nc.Name).Str("cidr", cidr).Msg("invalid cidr")
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
nc.IPNets = append(nc.IPNets, ipNet)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for n := range p.cfg.Upstream {
|
|
||||||
uc := p.cfg.Upstream[n]
|
|
||||||
uc.Init()
|
|
||||||
|
|
||||||
if uc.BootstrapIP == "" {
|
|
||||||
// resolve it manually and set the bootstrap ip
|
|
||||||
c := new(dns.Client)
|
|
||||||
m := new(dns.Msg)
|
|
||||||
m.SetQuestion(uc.Domain+".", dns.TypeA)
|
|
||||||
m.RecursionDesired = true
|
|
||||||
r, _, err := c.Exchange(m, net.JoinHostPort(bootstrapDNS, "53"))
|
|
||||||
if err != nil {
|
|
||||||
proxyLog.Error().Err(err).Msgf("could not resolve domain %s for upstream.%s", uc.Domain, n)
|
|
||||||
} else {
|
|
||||||
if r.Rcode != dns.RcodeSuccess {
|
|
||||||
proxyLog.Error().Msgf("could not resolve domain return code: %d, upstream.%s", r.Rcode, n)
|
|
||||||
} else {
|
|
||||||
for _, a := range r.Answer {
|
|
||||||
if ar, ok := a.(*dns.A); ok {
|
|
||||||
uc.BootstrapIP = ar.A.String()
|
|
||||||
proxyLog.Info().Str("bootstrap_ip", uc.BootstrapIP).Msgf("Setting bootstrap IP for upstream.%s", n)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for listenerNum := range p.cfg.Listener {
|
|
||||||
go func(listenerNum string) {
|
|
||||||
defer wg.Done()
|
|
||||||
listenerConfig := p.cfg.Listener[listenerNum]
|
|
||||||
upstreamConfig := p.cfg.Upstream[listenerNum]
|
|
||||||
if upstreamConfig == nil {
|
|
||||||
proxyLog.Error().Msgf("missing upstream config for: [listener.%s]", listenerNum)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
addr := net.JoinHostPort(listenerConfig.IP, strconv.Itoa(listenerConfig.Port))
|
|
||||||
proxyLog.Info().Msgf("Starting DNS server on listener.%s: %s", listenerNum, addr)
|
|
||||||
err := p.serveUDP(listenerNum)
|
|
||||||
if err != nil && !defaultConfigWritten {
|
|
||||||
proxyLog.Error().Err(err).Msgf("Unable to start dns proxy on listener.%s", listenerNum)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if opErr, ok := err.(*net.OpError); ok {
|
|
||||||
if sErr, ok := opErr.Err.(*os.SyscallError); ok && errors.Is(opErr.Err, syscall.EADDRINUSE) || errors.Is(sErr.Err, errWindowsAddrInUse) {
|
|
||||||
proxyLog.Warn().Msgf("Address %s already in used, pick a random one", addr)
|
|
||||||
pc, err := net.ListenPacket("udp", net.JoinHostPort(listenerConfig.IP, "0"))
|
|
||||||
if err != nil {
|
|
||||||
proxyLog.Error().Err(err).Msg("failed to listen packet")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
_, portStr, _ := net.SplitHostPort(pc.LocalAddr().String())
|
|
||||||
port, err := strconv.Atoi(portStr)
|
|
||||||
if err != nil {
|
|
||||||
proxyLog.Error().Err(err).Msg("malformed port")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
listenerConfig.Port = port
|
|
||||||
v.Set("listener", map[string]*ctrld.ListenerConfig{
|
|
||||||
"0": {
|
|
||||||
IP: "127.0.0.1",
|
|
||||||
Port: port,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
writeConfigFile()
|
|
||||||
proxyLog.Info().Msgf("Starting DNS server on listener.%s: %s", listenerNum, pc.LocalAddr())
|
|
||||||
// There can be a race between closing the listener and start our own UDP server, but it's
|
|
||||||
// rare, and we only do this once, so let conservative here.
|
|
||||||
if err := pc.Close(); err != nil {
|
|
||||||
proxyLog.Error().Err(err).Msg("failed to close packet conn")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := p.serveUDP(listenerNum); err != nil {
|
|
||||||
proxyLog.Error().Err(err).Msgf("Unable to start dns proxy on listener.%s", listenerNum)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}(listenerNum)
|
|
||||||
}
|
|
||||||
|
|
||||||
wg.Wait()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *prog) Stop(s service.Service) error {
|
|
||||||
if err := p.deAllocateIP(); err != nil {
|
|
||||||
mainLog.Error().Err(err).Msg("de-allocate ip failed")
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *prog) allocateIP(ip string) error {
|
|
||||||
if !p.cfg.Service.AllocateIP {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return allocateIP(ip)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *prog) deAllocateIP() error {
|
|
||||||
if !p.cfg.Service.AllocateIP {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
for _, lc := range p.cfg.Listener {
|
|
||||||
if err := deAllocateIP(lc.IP); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
80
cmd/ctrld_library/main.go
Normal file
80
cmd/ctrld_library/main.go
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
package ctrld_library
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/Control-D-Inc/ctrld/cmd/cli"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Controller holds global state
|
||||||
|
type Controller struct {
|
||||||
|
stopCh chan struct{}
|
||||||
|
AppCallback AppCallback
|
||||||
|
Config cli.AppConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewController provides reference to global state to be managed by android vpn service and iOS network extension.
|
||||||
|
// reference is not safe for concurrent use.
|
||||||
|
func NewController(appCallback AppCallback) *Controller {
|
||||||
|
return &Controller{AppCallback: appCallback}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AppCallback provides access to app instance.
|
||||||
|
type AppCallback interface {
|
||||||
|
Hostname() string
|
||||||
|
LanIp() string
|
||||||
|
MacAddress() string
|
||||||
|
Exit(error string)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start configures utility with config.toml from provided directory.
|
||||||
|
// This function will block until Stop is called
|
||||||
|
// Check port availability prior to calling it.
|
||||||
|
func (c *Controller) Start(CdUID string, HomeDir string, UpstreamProto string, logLevel int, logPath string) {
|
||||||
|
if c.stopCh == nil {
|
||||||
|
c.stopCh = make(chan struct{})
|
||||||
|
c.Config = cli.AppConfig{
|
||||||
|
CdUID: CdUID,
|
||||||
|
HomeDir: HomeDir,
|
||||||
|
UpstreamProto: UpstreamProto,
|
||||||
|
Verbose: logLevel,
|
||||||
|
LogPath: logPath,
|
||||||
|
}
|
||||||
|
appCallback := mapCallback(c.AppCallback)
|
||||||
|
cli.RunMobile(&c.Config, &appCallback, c.stopCh)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// As workaround to avoid circular dependency between cli and ctrld_library module
|
||||||
|
func mapCallback(callback AppCallback) cli.AppCallback {
|
||||||
|
return cli.AppCallback{
|
||||||
|
HostName: func() string {
|
||||||
|
return callback.Hostname()
|
||||||
|
},
|
||||||
|
LanIp: func() string {
|
||||||
|
return callback.LanIp()
|
||||||
|
},
|
||||||
|
MacAddress: func() string {
|
||||||
|
return callback.MacAddress()
|
||||||
|
},
|
||||||
|
Exit: func(err string) {
|
||||||
|
callback.Exit(err)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Controller) Stop(restart bool, pin int64) int {
|
||||||
|
var errorCode = 0
|
||||||
|
// Force disconnect without checking pin.
|
||||||
|
// In iOS restart is required if vpn detects no connectivity after network change.
|
||||||
|
if !restart {
|
||||||
|
errorCode = cli.CheckDeactivationPin(pin, c.stopCh)
|
||||||
|
}
|
||||||
|
if errorCode == 0 && c.stopCh != nil {
|
||||||
|
close(c.stopCh)
|
||||||
|
c.stopCh = nil
|
||||||
|
}
|
||||||
|
return errorCode
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Controller) IsRunning() bool {
|
||||||
|
return c.stopCh != nil
|
||||||
|
}
|
||||||
510
config_internal_test.go
Normal file
510
config_internal_test.go
Normal file
@@ -0,0 +1,510 @@
|
|||||||
|
package ctrld
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/url"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestUpstreamConfig_SetupBootstrapIP(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
uc *UpstreamConfig
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "doh/doh3",
|
||||||
|
uc: &UpstreamConfig{
|
||||||
|
Name: "doh",
|
||||||
|
Type: ResolverTypeDOH,
|
||||||
|
Endpoint: "https://freedns.controld.com/p2",
|
||||||
|
Timeout: 5000,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "doq/dot",
|
||||||
|
uc: &UpstreamConfig{
|
||||||
|
Name: "dot",
|
||||||
|
Type: ResolverTypeDOT,
|
||||||
|
Endpoint: "p2.freedns.controld.com",
|
||||||
|
Timeout: 5000,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tc := range tests {
|
||||||
|
tc := tc
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
// Enable parallel tests once https://github.com/microsoft/wmi/issues/165 fixed.
|
||||||
|
// t.Parallel()
|
||||||
|
tc.uc.Init()
|
||||||
|
tc.uc.SetupBootstrapIP()
|
||||||
|
if len(tc.uc.bootstrapIPs) == 0 {
|
||||||
|
t.Log(defaultNameservers())
|
||||||
|
t.Fatalf("could not bootstrap ip: %s", tc.uc.String())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpstreamConfig_Init(t *testing.T) {
|
||||||
|
u1, _ := url.Parse("https://example.com")
|
||||||
|
u2, _ := url.Parse("https://example.com?k=v")
|
||||||
|
u3, _ := url.Parse("https://freedns.controld.com/p1")
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
uc *UpstreamConfig
|
||||||
|
expected *UpstreamConfig
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
"doh+doh3",
|
||||||
|
&UpstreamConfig{
|
||||||
|
Name: "doh",
|
||||||
|
Type: "doh",
|
||||||
|
Endpoint: "https://example.com",
|
||||||
|
BootstrapIP: "",
|
||||||
|
Domain: "",
|
||||||
|
Timeout: 0,
|
||||||
|
},
|
||||||
|
&UpstreamConfig{
|
||||||
|
Name: "doh",
|
||||||
|
Type: "doh",
|
||||||
|
Endpoint: "https://example.com",
|
||||||
|
BootstrapIP: "",
|
||||||
|
Domain: "example.com",
|
||||||
|
Timeout: 0,
|
||||||
|
IPStack: IpStackBoth,
|
||||||
|
u: u1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"doh+doh3 with query param",
|
||||||
|
&UpstreamConfig{
|
||||||
|
Name: "doh",
|
||||||
|
Type: "doh",
|
||||||
|
Endpoint: "https://example.com?k=v",
|
||||||
|
BootstrapIP: "",
|
||||||
|
Domain: "",
|
||||||
|
Timeout: 0,
|
||||||
|
},
|
||||||
|
&UpstreamConfig{
|
||||||
|
Name: "doh",
|
||||||
|
Type: "doh",
|
||||||
|
Endpoint: "https://example.com?k=v",
|
||||||
|
BootstrapIP: "",
|
||||||
|
Domain: "example.com",
|
||||||
|
Timeout: 0,
|
||||||
|
IPStack: IpStackBoth,
|
||||||
|
u: u2,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"dot+doq",
|
||||||
|
&UpstreamConfig{
|
||||||
|
Name: "dot",
|
||||||
|
Type: "dot",
|
||||||
|
Endpoint: "freedns.controld.com:8853",
|
||||||
|
BootstrapIP: "",
|
||||||
|
Domain: "",
|
||||||
|
Timeout: 0,
|
||||||
|
},
|
||||||
|
&UpstreamConfig{
|
||||||
|
Name: "dot",
|
||||||
|
Type: "dot",
|
||||||
|
Endpoint: "freedns.controld.com:8853",
|
||||||
|
BootstrapIP: "",
|
||||||
|
Domain: "freedns.controld.com",
|
||||||
|
Timeout: 0,
|
||||||
|
IPStack: IpStackSplit,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"dot+doq without port",
|
||||||
|
&UpstreamConfig{
|
||||||
|
Name: "dot",
|
||||||
|
Type: "dot",
|
||||||
|
Endpoint: "freedns.controld.com",
|
||||||
|
BootstrapIP: "",
|
||||||
|
Domain: "",
|
||||||
|
Timeout: 0,
|
||||||
|
IPStack: IpStackSplit,
|
||||||
|
},
|
||||||
|
&UpstreamConfig{
|
||||||
|
Name: "dot",
|
||||||
|
Type: "dot",
|
||||||
|
Endpoint: "freedns.controld.com:853",
|
||||||
|
BootstrapIP: "",
|
||||||
|
Domain: "freedns.controld.com",
|
||||||
|
Timeout: 0,
|
||||||
|
IPStack: IpStackSplit,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"legacy",
|
||||||
|
&UpstreamConfig{
|
||||||
|
Name: "legacy",
|
||||||
|
Type: "legacy",
|
||||||
|
Endpoint: "1.2.3.4:53",
|
||||||
|
BootstrapIP: "",
|
||||||
|
Domain: "",
|
||||||
|
Timeout: 0,
|
||||||
|
},
|
||||||
|
&UpstreamConfig{
|
||||||
|
Name: "legacy",
|
||||||
|
Type: "legacy",
|
||||||
|
Endpoint: "1.2.3.4:53",
|
||||||
|
BootstrapIP: "1.2.3.4",
|
||||||
|
Domain: "1.2.3.4",
|
||||||
|
Timeout: 0,
|
||||||
|
IPStack: IpStackBoth,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"legacy without port",
|
||||||
|
&UpstreamConfig{
|
||||||
|
Name: "legacy",
|
||||||
|
Type: "legacy",
|
||||||
|
Endpoint: "1.2.3.4",
|
||||||
|
BootstrapIP: "",
|
||||||
|
Domain: "",
|
||||||
|
Timeout: 0,
|
||||||
|
},
|
||||||
|
&UpstreamConfig{
|
||||||
|
Name: "legacy",
|
||||||
|
Type: "legacy",
|
||||||
|
Endpoint: "1.2.3.4:53",
|
||||||
|
BootstrapIP: "1.2.3.4",
|
||||||
|
Domain: "1.2.3.4",
|
||||||
|
Timeout: 0,
|
||||||
|
IPStack: IpStackBoth,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"doh+doh3 with send client info set",
|
||||||
|
&UpstreamConfig{
|
||||||
|
Name: "doh",
|
||||||
|
Type: "doh",
|
||||||
|
Endpoint: "https://example.com?k=v",
|
||||||
|
BootstrapIP: "",
|
||||||
|
Domain: "",
|
||||||
|
Timeout: 0,
|
||||||
|
SendClientInfo: ptrBool(false),
|
||||||
|
IPStack: IpStackBoth,
|
||||||
|
},
|
||||||
|
&UpstreamConfig{
|
||||||
|
Name: "doh",
|
||||||
|
Type: "doh",
|
||||||
|
Endpoint: "https://example.com?k=v",
|
||||||
|
BootstrapIP: "",
|
||||||
|
Domain: "example.com",
|
||||||
|
Timeout: 0,
|
||||||
|
SendClientInfo: ptrBool(false),
|
||||||
|
IPStack: IpStackBoth,
|
||||||
|
u: u2,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"h3",
|
||||||
|
&UpstreamConfig{
|
||||||
|
Name: "doh3",
|
||||||
|
Type: "doh3",
|
||||||
|
Endpoint: "h3://example.com",
|
||||||
|
BootstrapIP: "",
|
||||||
|
Domain: "",
|
||||||
|
Timeout: 0,
|
||||||
|
},
|
||||||
|
&UpstreamConfig{
|
||||||
|
Name: "doh3",
|
||||||
|
Type: "doh3",
|
||||||
|
Endpoint: "https://example.com",
|
||||||
|
BootstrapIP: "",
|
||||||
|
Domain: "example.com",
|
||||||
|
Timeout: 0,
|
||||||
|
IPStack: IpStackBoth,
|
||||||
|
u: u1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"h3 without type",
|
||||||
|
&UpstreamConfig{
|
||||||
|
Name: "doh3",
|
||||||
|
Endpoint: "h3://example.com",
|
||||||
|
BootstrapIP: "",
|
||||||
|
Domain: "",
|
||||||
|
Timeout: 0,
|
||||||
|
},
|
||||||
|
&UpstreamConfig{
|
||||||
|
Name: "doh3",
|
||||||
|
Type: "doh3",
|
||||||
|
Endpoint: "https://example.com",
|
||||||
|
BootstrapIP: "",
|
||||||
|
Domain: "example.com",
|
||||||
|
Timeout: 0,
|
||||||
|
IPStack: IpStackBoth,
|
||||||
|
u: u1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"sdns -> doh",
|
||||||
|
&UpstreamConfig{
|
||||||
|
Name: "sdns",
|
||||||
|
Type: "sdns",
|
||||||
|
Endpoint: "sdns://AgMAAAAAAAAACjc2Ljc2LjIuMTEAFGZyZWVkbnMuY29udHJvbGQuY29tAy9wMQ",
|
||||||
|
BootstrapIP: "",
|
||||||
|
Domain: "",
|
||||||
|
Timeout: 0,
|
||||||
|
IPStack: IpStackBoth,
|
||||||
|
},
|
||||||
|
&UpstreamConfig{
|
||||||
|
Name: "sdns",
|
||||||
|
Type: "doh",
|
||||||
|
Endpoint: "https://freedns.controld.com/p1",
|
||||||
|
BootstrapIP: "76.76.2.11",
|
||||||
|
Domain: "freedns.controld.com",
|
||||||
|
Timeout: 0,
|
||||||
|
IPStack: IpStackBoth,
|
||||||
|
u: u3,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"sdns -> dot",
|
||||||
|
&UpstreamConfig{
|
||||||
|
Name: "sdns",
|
||||||
|
Type: "sdns",
|
||||||
|
Endpoint: "sdns://AwcAAAAAAAAACjc2Ljc2LjIuMTEAFGZyZWVkbnMuY29udHJvbGQuY29t",
|
||||||
|
BootstrapIP: "",
|
||||||
|
Domain: "",
|
||||||
|
Timeout: 0,
|
||||||
|
IPStack: IpStackBoth,
|
||||||
|
},
|
||||||
|
&UpstreamConfig{
|
||||||
|
Name: "sdns",
|
||||||
|
Type: "dot",
|
||||||
|
Endpoint: "freedns.controld.com:843",
|
||||||
|
BootstrapIP: "76.76.2.11",
|
||||||
|
Domain: "freedns.controld.com",
|
||||||
|
Timeout: 0,
|
||||||
|
IPStack: IpStackBoth,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"sdns -> doq",
|
||||||
|
&UpstreamConfig{
|
||||||
|
Name: "sdns",
|
||||||
|
Type: "sdns",
|
||||||
|
Endpoint: "sdns://BAcAAAAAAAAACjc2Ljc2LjIuMTEAFGZyZWVkbnMuY29udHJvbGQuY29t",
|
||||||
|
BootstrapIP: "",
|
||||||
|
Domain: "",
|
||||||
|
Timeout: 0,
|
||||||
|
IPStack: IpStackBoth,
|
||||||
|
},
|
||||||
|
&UpstreamConfig{
|
||||||
|
Name: "sdns",
|
||||||
|
Type: "doq",
|
||||||
|
Endpoint: "freedns.controld.com:784",
|
||||||
|
BootstrapIP: "76.76.2.11",
|
||||||
|
Domain: "freedns.controld.com",
|
||||||
|
Timeout: 0,
|
||||||
|
IPStack: IpStackBoth,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"sdns -> legacy",
|
||||||
|
&UpstreamConfig{
|
||||||
|
Name: "sdns",
|
||||||
|
Type: "sdns",
|
||||||
|
Endpoint: "sdns://AAcAAAAAAAAACjc2Ljc2LjIuMTE",
|
||||||
|
BootstrapIP: "",
|
||||||
|
Domain: "",
|
||||||
|
Timeout: 0,
|
||||||
|
IPStack: IpStackBoth,
|
||||||
|
},
|
||||||
|
&UpstreamConfig{
|
||||||
|
Name: "sdns",
|
||||||
|
Type: "legacy",
|
||||||
|
Endpoint: "76.76.2.11:53",
|
||||||
|
BootstrapIP: "76.76.2.11",
|
||||||
|
Domain: "76.76.2.11",
|
||||||
|
Timeout: 0,
|
||||||
|
IPStack: IpStackBoth,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"sdns without type",
|
||||||
|
&UpstreamConfig{
|
||||||
|
Name: "sdns",
|
||||||
|
Endpoint: "sdns://AAcAAAAAAAAACjc2Ljc2LjIuMTE",
|
||||||
|
BootstrapIP: "",
|
||||||
|
Domain: "",
|
||||||
|
Timeout: 0,
|
||||||
|
IPStack: IpStackBoth,
|
||||||
|
},
|
||||||
|
&UpstreamConfig{
|
||||||
|
Name: "sdns",
|
||||||
|
Type: "legacy",
|
||||||
|
Endpoint: "76.76.2.11:53",
|
||||||
|
BootstrapIP: "76.76.2.11",
|
||||||
|
Domain: "76.76.2.11",
|
||||||
|
Timeout: 0,
|
||||||
|
IPStack: IpStackBoth,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
tc := tc
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
tc.uc.Init()
|
||||||
|
tc.uc.uid = "" // we don't care about the uid.
|
||||||
|
assert.Equal(t, tc.expected, tc.uc)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpstreamConfig_VerifyDomain(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
uc *UpstreamConfig
|
||||||
|
verifyDomain string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
controlDComDomain,
|
||||||
|
&UpstreamConfig{Endpoint: "https://freedns.controld.com/p2"},
|
||||||
|
controldVerifiedDomain[controlDComDomain],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
controlDDevDomain,
|
||||||
|
&UpstreamConfig{Endpoint: "https://freedns.controld.dev/p2"},
|
||||||
|
controldVerifiedDomain[controlDDevDomain],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"non-ControlD upstream",
|
||||||
|
&UpstreamConfig{Endpoint: "https://dns.google/dns-query"},
|
||||||
|
"",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
tc := tc
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
if got := tc.uc.VerifyDomain(); got != tc.verifyDomain {
|
||||||
|
t.Errorf("unexpected verify domain, want: %q, got: %q", tc.verifyDomain, got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpstreamConfig_UpstreamSendClientInfo(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
uc *UpstreamConfig
|
||||||
|
sendClientInfo bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
"default with controld upstream DoH",
|
||||||
|
&UpstreamConfig{Endpoint: "https://freedns.controld.com/p2", Type: ResolverTypeDOH},
|
||||||
|
true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"default with controld upstream DoH3",
|
||||||
|
&UpstreamConfig{Endpoint: "https://freedns.controld.com/p2", Type: ResolverTypeDOH3},
|
||||||
|
true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"default with non-ControlD upstream",
|
||||||
|
&UpstreamConfig{Endpoint: "https://dns.google/dns-query", Type: ResolverTypeDOH},
|
||||||
|
false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"set false with controld upstream",
|
||||||
|
&UpstreamConfig{Endpoint: "https://freedns.controld.com/p2", Type: ResolverTypeDOH, SendClientInfo: ptrBool(false)},
|
||||||
|
false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"set true with controld upstream",
|
||||||
|
&UpstreamConfig{Endpoint: "https://freedns.controld.com/p2", SendClientInfo: ptrBool(true)},
|
||||||
|
true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"set false with non-ControlD upstream",
|
||||||
|
&UpstreamConfig{Endpoint: "https://dns.google/dns-query", SendClientInfo: ptrBool(false)},
|
||||||
|
false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"set true with non-ControlD upstream",
|
||||||
|
&UpstreamConfig{Endpoint: "https://dns.google/dns-query", Type: ResolverTypeDOH, SendClientInfo: ptrBool(true)},
|
||||||
|
true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
tc := tc
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
if got := tc.uc.UpstreamSendClientInfo(); got != tc.sendClientInfo {
|
||||||
|
t.Errorf("unexpected result, want: %v, got: %v", tc.sendClientInfo, got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpstreamConfig_IsDiscoverable(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
uc *UpstreamConfig
|
||||||
|
discoverable bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
"loopback",
|
||||||
|
&UpstreamConfig{Endpoint: "127.0.0.1", Type: ResolverTypeLegacy},
|
||||||
|
true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"rfc1918",
|
||||||
|
&UpstreamConfig{Endpoint: "192.168.1.1", Type: ResolverTypeLegacy},
|
||||||
|
true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"CGNAT",
|
||||||
|
&UpstreamConfig{Endpoint: "100.66.67.68", Type: ResolverTypeLegacy},
|
||||||
|
true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Public IP",
|
||||||
|
&UpstreamConfig{Endpoint: "8.8.8.8", Type: ResolverTypeLegacy},
|
||||||
|
false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"override discoverable",
|
||||||
|
&UpstreamConfig{Endpoint: "127.0.0.1", Type: ResolverTypeLegacy, Discoverable: ptrBool(false)},
|
||||||
|
false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"override non-public",
|
||||||
|
&UpstreamConfig{Endpoint: "1.1.1.1", Type: ResolverTypeLegacy, Discoverable: ptrBool(true)},
|
||||||
|
true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"non-legacy upstream",
|
||||||
|
&UpstreamConfig{Endpoint: "https://192.168.1.1/custom-doh", Type: ResolverTypeDOH},
|
||||||
|
false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
tc := tc
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
tc.uc.Init()
|
||||||
|
if got := tc.uc.IsDiscoverable(); got != tc.discoverable {
|
||||||
|
t.Errorf("unexpected result, want: %v, got: %v", tc.discoverable, got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func ptrBool(b bool) *bool {
|
||||||
|
return &b
|
||||||
|
}
|
||||||
160
config_quic.go
Normal file
160
config_quic.go
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
package ctrld
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/tls"
|
||||||
|
"errors"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"runtime"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/miekg/dns"
|
||||||
|
"github.com/quic-go/quic-go"
|
||||||
|
"github.com/quic-go/quic-go/http3"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (uc *UpstreamConfig) setupDOH3Transport() {
|
||||||
|
switch uc.IPStack {
|
||||||
|
case IpStackBoth, "":
|
||||||
|
uc.http3RoundTripper = uc.newDOH3Transport(uc.bootstrapIPs)
|
||||||
|
case IpStackV4:
|
||||||
|
uc.http3RoundTripper = uc.newDOH3Transport(uc.bootstrapIPs4)
|
||||||
|
case IpStackV6:
|
||||||
|
uc.http3RoundTripper = uc.newDOH3Transport(uc.bootstrapIPs6)
|
||||||
|
case IpStackSplit:
|
||||||
|
uc.http3RoundTripper4 = uc.newDOH3Transport(uc.bootstrapIPs4)
|
||||||
|
if HasIPv6() {
|
||||||
|
uc.http3RoundTripper6 = uc.newDOH3Transport(uc.bootstrapIPs6)
|
||||||
|
} else {
|
||||||
|
uc.http3RoundTripper6 = uc.http3RoundTripper4
|
||||||
|
}
|
||||||
|
uc.http3RoundTripper = uc.newDOH3Transport(uc.bootstrapIPs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (uc *UpstreamConfig) newDOH3Transport(addrs []string) http.RoundTripper {
|
||||||
|
rt := &http3.Transport{}
|
||||||
|
rt.TLSClientConfig = &tls.Config{RootCAs: uc.certPool}
|
||||||
|
rt.Dial = func(ctx context.Context, addr string, tlsCfg *tls.Config, cfg *quic.Config) (quic.EarlyConnection, error) {
|
||||||
|
_, port, _ := net.SplitHostPort(addr)
|
||||||
|
// if we have a bootstrap ip set, use it to avoid DNS lookup
|
||||||
|
if uc.BootstrapIP != "" {
|
||||||
|
addr = net.JoinHostPort(uc.BootstrapIP, port)
|
||||||
|
ProxyLogger.Load().Debug().Msgf("sending doh3 request to: %s", addr)
|
||||||
|
udpConn, err := net.ListenUDP("udp", nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
remoteAddr, err := net.ResolveUDPAddr("udp", addr)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return quic.DialEarly(ctx, udpConn, remoteAddr, tlsCfg, cfg)
|
||||||
|
}
|
||||||
|
dialAddrs := make([]string, len(addrs))
|
||||||
|
for i := range addrs {
|
||||||
|
dialAddrs[i] = net.JoinHostPort(addrs[i], port)
|
||||||
|
}
|
||||||
|
pd := &quicParallelDialer{}
|
||||||
|
conn, err := pd.Dial(ctx, dialAddrs, tlsCfg, cfg)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
ProxyLogger.Load().Debug().Msgf("sending doh3 request to: %s", conn.RemoteAddr())
|
||||||
|
return conn, err
|
||||||
|
}
|
||||||
|
runtime.SetFinalizer(rt, func(rt *http3.Transport) {
|
||||||
|
rt.CloseIdleConnections()
|
||||||
|
})
|
||||||
|
return rt
|
||||||
|
}
|
||||||
|
|
||||||
|
func (uc *UpstreamConfig) doh3Transport(dnsType uint16) http.RoundTripper {
|
||||||
|
uc.transportOnce.Do(func() {
|
||||||
|
uc.SetupTransport()
|
||||||
|
})
|
||||||
|
if uc.rebootstrap.CompareAndSwap(true, false) {
|
||||||
|
uc.SetupTransport()
|
||||||
|
}
|
||||||
|
switch uc.IPStack {
|
||||||
|
case IpStackBoth, IpStackV4, IpStackV6:
|
||||||
|
return uc.http3RoundTripper
|
||||||
|
case IpStackSplit:
|
||||||
|
switch dnsType {
|
||||||
|
case dns.TypeA:
|
||||||
|
return uc.http3RoundTripper4
|
||||||
|
default:
|
||||||
|
return uc.http3RoundTripper6
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return uc.http3RoundTripper
|
||||||
|
}
|
||||||
|
|
||||||
|
// Putting the code for quic parallel dialer here:
|
||||||
|
//
|
||||||
|
// - quic dialer is different with net.Dialer
|
||||||
|
// - simplification for quic free version
|
||||||
|
type parallelDialerResult struct {
|
||||||
|
conn quic.EarlyConnection
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
type quicParallelDialer struct{}
|
||||||
|
|
||||||
|
// Dial performs parallel dialing to the given address list.
|
||||||
|
func (d *quicParallelDialer) Dial(ctx context.Context, addrs []string, tlsCfg *tls.Config, cfg *quic.Config) (quic.EarlyConnection, error) {
|
||||||
|
if len(addrs) == 0 {
|
||||||
|
return nil, errors.New("empty addresses")
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithCancel(ctx)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
done := make(chan struct{})
|
||||||
|
defer close(done)
|
||||||
|
ch := make(chan *parallelDialerResult, len(addrs))
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
wg.Add(len(addrs))
|
||||||
|
go func() {
|
||||||
|
wg.Wait()
|
||||||
|
close(ch)
|
||||||
|
}()
|
||||||
|
|
||||||
|
for _, addr := range addrs {
|
||||||
|
go func(addr string) {
|
||||||
|
defer wg.Done()
|
||||||
|
remoteAddr, err := net.ResolveUDPAddr("udp", addr)
|
||||||
|
if err != nil {
|
||||||
|
ch <- ¶llelDialerResult{conn: nil, err: err}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
udpConn, err := net.ListenUDP("udp", nil)
|
||||||
|
if err != nil {
|
||||||
|
ch <- ¶llelDialerResult{conn: nil, err: err}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
conn, err := quic.DialEarly(ctx, udpConn, remoteAddr, tlsCfg, cfg)
|
||||||
|
select {
|
||||||
|
case ch <- ¶llelDialerResult{conn: conn, err: err}:
|
||||||
|
case <-done:
|
||||||
|
if conn != nil {
|
||||||
|
conn.CloseWithError(quic.ApplicationErrorCode(http3.ErrCodeNoError), "")
|
||||||
|
}
|
||||||
|
if udpConn != nil {
|
||||||
|
udpConn.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}(addr)
|
||||||
|
}
|
||||||
|
|
||||||
|
errs := make([]error, 0, len(addrs))
|
||||||
|
for res := range ch {
|
||||||
|
if res.err == nil {
|
||||||
|
cancel()
|
||||||
|
return res.conn, res.err
|
||||||
|
}
|
||||||
|
errs = append(errs, res.err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, errors.Join(errs...)
|
||||||
|
}
|
||||||
294
config_test.go
294
config_test.go
@@ -1,7 +1,11 @@
|
|||||||
package ctrld_test
|
package ctrld_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/go-playground/validator/v10"
|
"github.com/go-playground/validator/v10"
|
||||||
"github.com/spf13/viper"
|
"github.com/spf13/viper"
|
||||||
@@ -19,15 +23,19 @@ func TestLoadConfig(t *testing.T) {
|
|||||||
|
|
||||||
assert.Equal(t, "info", cfg.Service.LogLevel)
|
assert.Equal(t, "info", cfg.Service.LogLevel)
|
||||||
assert.Equal(t, "/path/to/log.log", cfg.Service.LogPath)
|
assert.Equal(t, "/path/to/log.log", cfg.Service.LogPath)
|
||||||
|
assert.Equal(t, false, *cfg.Service.DnsWatchdogEnabled)
|
||||||
|
assert.Equal(t, time.Duration(20*time.Second), *cfg.Service.DnsWatchdogInvterval)
|
||||||
|
|
||||||
assert.Len(t, cfg.Network, 2)
|
assert.Len(t, cfg.Network, 2)
|
||||||
assert.Contains(t, cfg.Network, "0")
|
assert.Contains(t, cfg.Network, "0")
|
||||||
assert.Contains(t, cfg.Network, "1")
|
assert.Contains(t, cfg.Network, "1")
|
||||||
|
|
||||||
assert.Len(t, cfg.Upstream, 3)
|
assert.Len(t, cfg.Upstream, 4)
|
||||||
assert.Contains(t, cfg.Upstream, "0")
|
assert.Contains(t, cfg.Upstream, "0")
|
||||||
assert.Contains(t, cfg.Upstream, "1")
|
assert.Contains(t, cfg.Upstream, "1")
|
||||||
assert.Contains(t, cfg.Upstream, "2")
|
assert.Contains(t, cfg.Upstream, "2")
|
||||||
|
assert.Contains(t, cfg.Upstream, "3")
|
||||||
|
assert.NotNil(t, cfg.Upstream["3"].SendClientInfo)
|
||||||
|
|
||||||
assert.Len(t, cfg.Listener, 2)
|
assert.Len(t, cfg.Listener, 2)
|
||||||
assert.Contains(t, cfg.Listener, "0")
|
assert.Contains(t, cfg.Listener, "0")
|
||||||
@@ -42,16 +50,37 @@ func TestLoadConfig(t *testing.T) {
|
|||||||
assert.Len(t, cfg.Listener["0"].Policy.Rules, 2)
|
assert.Len(t, cfg.Listener["0"].Policy.Rules, 2)
|
||||||
assert.Contains(t, cfg.Listener["0"].Policy.Rules[0], "*.ru")
|
assert.Contains(t, cfg.Listener["0"].Policy.Rules[0], "*.ru")
|
||||||
assert.Contains(t, cfg.Listener["0"].Policy.Rules[1], "*.local.host")
|
assert.Contains(t, cfg.Listener["0"].Policy.Rules[1], "*.local.host")
|
||||||
|
|
||||||
|
assert.True(t, cfg.HasUpstreamSendClientInfo())
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLoadDefaultConfig(t *testing.T) {
|
func TestLoadDefaultConfig(t *testing.T) {
|
||||||
cfg := defaultConfig(t)
|
cfg := defaultConfig(t)
|
||||||
validate := validator.New()
|
validate := validator.New()
|
||||||
require.NoError(t, ctrld.ValidateConfig(validate, cfg))
|
require.NoError(t, ctrld.ValidateConfig(validate, cfg))
|
||||||
assert.Len(t, cfg.Listener, 1)
|
if assert.Len(t, cfg.Listener, 1) {
|
||||||
|
l0 := cfg.Listener["0"]
|
||||||
|
require.NotNil(t, l0.Policy)
|
||||||
|
assert.Len(t, l0.Policy.Networks, 1)
|
||||||
|
assert.Len(t, l0.Policy.Rules, 2)
|
||||||
|
}
|
||||||
assert.Len(t, cfg.Upstream, 2)
|
assert.Len(t, cfg.Upstream, 2)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestConfigOverride(t *testing.T) {
|
||||||
|
v := viper.NewWithOptions(viper.KeyDelimiter("::"))
|
||||||
|
ctrld.InitConfig(v, "test_load_config")
|
||||||
|
v.SetConfigType("toml")
|
||||||
|
require.NoError(t, v.ReadConfig(strings.NewReader(testhelper.SampleConfigStr(t))))
|
||||||
|
cfg := ctrld.Config{Listener: map[string]*ctrld.ListenerConfig{
|
||||||
|
"0": {IP: "127.0.0.1", Port: 53},
|
||||||
|
}}
|
||||||
|
require.NoError(t, v.Unmarshal(&cfg))
|
||||||
|
|
||||||
|
assert.Equal(t, "10.10.42.69", cfg.Listener["1"].IP)
|
||||||
|
assert.Equal(t, 1337, cfg.Listener["1"].Port)
|
||||||
|
}
|
||||||
|
|
||||||
func TestConfigValidation(t *testing.T) {
|
func TestConfigValidation(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
@@ -61,6 +90,7 @@ func TestConfigValidation(t *testing.T) {
|
|||||||
{"invalid Config", &ctrld.Config{}, true},
|
{"invalid Config", &ctrld.Config{}, true},
|
||||||
{"default Config", defaultConfig(t), false},
|
{"default Config", defaultConfig(t), false},
|
||||||
{"sample Config", testhelper.SampleConfig(t), false},
|
{"sample Config", testhelper.SampleConfig(t), false},
|
||||||
|
{"empty listener IP", emptyListenerIP(t), false},
|
||||||
{"invalid cidr", invalidNetworkConfig(t), true},
|
{"invalid cidr", invalidNetworkConfig(t), true},
|
||||||
{"invalid upstream type", invalidUpstreamType(t), true},
|
{"invalid upstream type", invalidUpstreamType(t), true},
|
||||||
{"invalid upstream timeout", invalidUpstreamTimeout(t), true},
|
{"invalid upstream timeout", invalidUpstreamTimeout(t), true},
|
||||||
@@ -69,6 +99,19 @@ func TestConfigValidation(t *testing.T) {
|
|||||||
{"invalid listener port", invalidListenerPort(t), true},
|
{"invalid listener port", invalidListenerPort(t), true},
|
||||||
{"os upstream", configWithOsUpstream(t), false},
|
{"os upstream", configWithOsUpstream(t), false},
|
||||||
{"invalid rules", configWithInvalidRules(t), true},
|
{"invalid rules", configWithInvalidRules(t), true},
|
||||||
|
{"invalid dns rcodes", configWithInvalidRcodes(t), true},
|
||||||
|
{"invalid max concurrent requests", configWithInvalidMaxConcurrentRequests(t), true},
|
||||||
|
{"non-existed lease file", configWithNonExistedLeaseFile(t), true},
|
||||||
|
{"lease file format required if lease file exist", configWithExistedLeaseFile(t), true},
|
||||||
|
{"invalid lease file format", configWithInvalidLeaseFileFormat(t), true},
|
||||||
|
{"invalid doh/doh3 endpoint", configWithInvalidDoHEndpoint(t), true},
|
||||||
|
{"invalid client id pref", configWithInvalidClientIDPref(t), true},
|
||||||
|
{"doh endpoint without scheme", dohUpstreamEndpointWithoutScheme(t), false},
|
||||||
|
{"doh endpoint without type", dohUpstreamEndpointWithoutType(t), true},
|
||||||
|
{"doh3 endpoint without type", doh3UpstreamEndpointWithoutType(t), false},
|
||||||
|
{"sdns endpoint without type", sdnsUpstreamEndpointWithoutType(t), false},
|
||||||
|
{"maximum number of flush cache domains", configWithInvalidFlushCacheDomain(t), true},
|
||||||
|
{"kea dhcp4 format", configWithDhcp4KeaFormat(t), false},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tc := range tests {
|
for _, tc := range tests {
|
||||||
@@ -88,6 +131,44 @@ func TestConfigValidation(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestConfigValidationDoNotChangeEndpoint(t *testing.T) {
|
||||||
|
cfg := configWithInvalidDoHEndpoint(t)
|
||||||
|
endpointMap := map[string]struct{}{}
|
||||||
|
for _, uc := range cfg.Upstream {
|
||||||
|
endpointMap[uc.Endpoint] = struct{}{}
|
||||||
|
}
|
||||||
|
validate := validator.New()
|
||||||
|
_ = ctrld.ValidateConfig(validate, cfg)
|
||||||
|
for _, uc := range cfg.Upstream {
|
||||||
|
if _, ok := endpointMap[uc.Endpoint]; !ok {
|
||||||
|
t.Fatalf("expected endpoint '%s' to exist", uc.Endpoint)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConfigDiscoverOverride(t *testing.T) {
|
||||||
|
v := viper.NewWithOptions(viper.KeyDelimiter("::"))
|
||||||
|
ctrld.InitConfig(v, "test_config_discover_override")
|
||||||
|
v.SetConfigType("toml")
|
||||||
|
configStr := `
|
||||||
|
[service]
|
||||||
|
discover_arp = false
|
||||||
|
discover_dhcp = false
|
||||||
|
discover_hosts = false
|
||||||
|
discover_mdns = false
|
||||||
|
discover_ptr = false
|
||||||
|
`
|
||||||
|
require.NoError(t, v.ReadConfig(strings.NewReader(configStr)))
|
||||||
|
cfg := ctrld.Config{}
|
||||||
|
require.NoError(t, v.Unmarshal(&cfg))
|
||||||
|
|
||||||
|
require.False(t, *cfg.Service.DiscoverARP)
|
||||||
|
require.False(t, *cfg.Service.DiscoverDHCP)
|
||||||
|
require.False(t, *cfg.Service.DiscoverHosts)
|
||||||
|
require.False(t, *cfg.Service.DiscoverMDNS)
|
||||||
|
require.False(t, *cfg.Service.DiscoverPtr)
|
||||||
|
}
|
||||||
|
|
||||||
func defaultConfig(t *testing.T) *ctrld.Config {
|
func defaultConfig(t *testing.T) *ctrld.Config {
|
||||||
v := viper.New()
|
v := viper.New()
|
||||||
ctrld.InitConfig(v, "test_load_default_config")
|
ctrld.InitConfig(v, "test_load_default_config")
|
||||||
@@ -111,6 +192,33 @@ func invalidUpstreamType(t *testing.T) *ctrld.Config {
|
|||||||
return cfg
|
return cfg
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func dohUpstreamEndpointWithoutScheme(t *testing.T) *ctrld.Config {
|
||||||
|
cfg := defaultConfig(t)
|
||||||
|
cfg.Upstream["0"].Endpoint = "freedns.controld.com/p1"
|
||||||
|
return cfg
|
||||||
|
}
|
||||||
|
|
||||||
|
func dohUpstreamEndpointWithoutType(t *testing.T) *ctrld.Config {
|
||||||
|
cfg := defaultConfig(t)
|
||||||
|
cfg.Upstream["0"].Endpoint = "https://freedns.controld.com/p1"
|
||||||
|
cfg.Upstream["0"].Type = ""
|
||||||
|
return cfg
|
||||||
|
}
|
||||||
|
|
||||||
|
func doh3UpstreamEndpointWithoutType(t *testing.T) *ctrld.Config {
|
||||||
|
cfg := defaultConfig(t)
|
||||||
|
cfg.Upstream["0"].Endpoint = "h3://freedns.controld.com/p1"
|
||||||
|
cfg.Upstream["0"].Type = ""
|
||||||
|
return cfg
|
||||||
|
}
|
||||||
|
|
||||||
|
func sdnsUpstreamEndpointWithoutType(t *testing.T) *ctrld.Config {
|
||||||
|
cfg := defaultConfig(t)
|
||||||
|
cfg.Upstream["0"].Endpoint = "sdns://AgMAAAAAAAAACjc2Ljc2LjIuMTEAFGZyZWVkbnMuY29udHJvbGQuY29tAy9wMQ"
|
||||||
|
cfg.Upstream["0"].Type = ""
|
||||||
|
return cfg
|
||||||
|
}
|
||||||
|
|
||||||
func invalidUpstreamTimeout(t *testing.T) *ctrld.Config {
|
func invalidUpstreamTimeout(t *testing.T) *ctrld.Config {
|
||||||
cfg := defaultConfig(t)
|
cfg := defaultConfig(t)
|
||||||
cfg.Upstream["0"].Timeout = -1
|
cfg.Upstream["0"].Timeout = -1
|
||||||
@@ -129,9 +237,15 @@ func invalidListenerIP(t *testing.T) *ctrld.Config {
|
|||||||
return cfg
|
return cfg
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func emptyListenerIP(t *testing.T) *ctrld.Config {
|
||||||
|
cfg := defaultConfig(t)
|
||||||
|
cfg.Listener["0"].IP = ""
|
||||||
|
return cfg
|
||||||
|
}
|
||||||
|
|
||||||
func invalidListenerPort(t *testing.T) *ctrld.Config {
|
func invalidListenerPort(t *testing.T) *ctrld.Config {
|
||||||
cfg := defaultConfig(t)
|
cfg := defaultConfig(t)
|
||||||
cfg.Listener["0"].Port = 0
|
cfg.Listener["0"].Port = -1
|
||||||
return cfg
|
return cfg
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -155,115 +269,69 @@ func configWithInvalidRules(t *testing.T) *ctrld.Config {
|
|||||||
return cfg
|
return cfg
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestUpstreamConfig_Init(t *testing.T) {
|
func configWithInvalidRcodes(t *testing.T) *ctrld.Config {
|
||||||
tests := []struct {
|
cfg := defaultConfig(t)
|
||||||
name string
|
cfg.Listener["0"].Policy = &ctrld.ListenerPolicyConfig{
|
||||||
uc *ctrld.UpstreamConfig
|
Name: "Policy with invalid Rcodes",
|
||||||
expected *ctrld.UpstreamConfig
|
Networks: []ctrld.Rule{{"*.com": []string{"upstream.0"}}},
|
||||||
}{
|
FailoverRcodes: []string{"foo"},
|
||||||
{
|
|
||||||
"doh+doh3",
|
|
||||||
&ctrld.UpstreamConfig{
|
|
||||||
Name: "doh",
|
|
||||||
Type: "doh",
|
|
||||||
Endpoint: "https://example.com",
|
|
||||||
BootstrapIP: "",
|
|
||||||
Domain: "",
|
|
||||||
Timeout: 0,
|
|
||||||
},
|
|
||||||
&ctrld.UpstreamConfig{
|
|
||||||
Name: "doh",
|
|
||||||
Type: "doh",
|
|
||||||
Endpoint: "https://example.com",
|
|
||||||
BootstrapIP: "",
|
|
||||||
Domain: "example.com",
|
|
||||||
Timeout: 0,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"dot+doq",
|
|
||||||
&ctrld.UpstreamConfig{
|
|
||||||
Name: "dot",
|
|
||||||
Type: "dot",
|
|
||||||
Endpoint: "freedns.controld.com:8853",
|
|
||||||
BootstrapIP: "",
|
|
||||||
Domain: "",
|
|
||||||
Timeout: 0,
|
|
||||||
},
|
|
||||||
&ctrld.UpstreamConfig{
|
|
||||||
Name: "dot",
|
|
||||||
Type: "dot",
|
|
||||||
Endpoint: "freedns.controld.com:8853",
|
|
||||||
BootstrapIP: "",
|
|
||||||
Domain: "freedns.controld.com",
|
|
||||||
Timeout: 0,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"dot+doq without port",
|
|
||||||
&ctrld.UpstreamConfig{
|
|
||||||
Name: "dot",
|
|
||||||
Type: "dot",
|
|
||||||
Endpoint: "freedns.controld.com",
|
|
||||||
BootstrapIP: "",
|
|
||||||
Domain: "",
|
|
||||||
Timeout: 0,
|
|
||||||
},
|
|
||||||
&ctrld.UpstreamConfig{
|
|
||||||
Name: "dot",
|
|
||||||
Type: "dot",
|
|
||||||
Endpoint: "freedns.controld.com:853",
|
|
||||||
BootstrapIP: "",
|
|
||||||
Domain: "freedns.controld.com",
|
|
||||||
Timeout: 0,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"legacy",
|
|
||||||
&ctrld.UpstreamConfig{
|
|
||||||
Name: "legacy",
|
|
||||||
Type: "legacy",
|
|
||||||
Endpoint: "1.2.3.4:53",
|
|
||||||
BootstrapIP: "",
|
|
||||||
Domain: "",
|
|
||||||
Timeout: 0,
|
|
||||||
},
|
|
||||||
&ctrld.UpstreamConfig{
|
|
||||||
Name: "legacy",
|
|
||||||
Type: "legacy",
|
|
||||||
Endpoint: "1.2.3.4:53",
|
|
||||||
BootstrapIP: "1.2.3.4",
|
|
||||||
Domain: "1.2.3.4",
|
|
||||||
Timeout: 0,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"legacy without port",
|
|
||||||
&ctrld.UpstreamConfig{
|
|
||||||
Name: "legacy",
|
|
||||||
Type: "legacy",
|
|
||||||
Endpoint: "1.2.3.4",
|
|
||||||
BootstrapIP: "",
|
|
||||||
Domain: "",
|
|
||||||
Timeout: 0,
|
|
||||||
},
|
|
||||||
&ctrld.UpstreamConfig{
|
|
||||||
Name: "legacy",
|
|
||||||
Type: "legacy",
|
|
||||||
Endpoint: "1.2.3.4:53",
|
|
||||||
BootstrapIP: "1.2.3.4",
|
|
||||||
Domain: "1.2.3.4",
|
|
||||||
Timeout: 0,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tc := range tests {
|
|
||||||
tc := tc
|
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
tc.uc.Init()
|
|
||||||
assert.Equal(t, tc.expected, tc.uc)
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
return cfg
|
||||||
|
}
|
||||||
|
|
||||||
|
func configWithInvalidMaxConcurrentRequests(t *testing.T) *ctrld.Config {
|
||||||
|
cfg := defaultConfig(t)
|
||||||
|
n := -1
|
||||||
|
cfg.Service.MaxConcurrentRequests = &n
|
||||||
|
return cfg
|
||||||
|
}
|
||||||
|
|
||||||
|
func configWithNonExistedLeaseFile(t *testing.T) *ctrld.Config {
|
||||||
|
cfg := defaultConfig(t)
|
||||||
|
cfg.Service.DHCPLeaseFile = "non-existed"
|
||||||
|
return cfg
|
||||||
|
}
|
||||||
|
|
||||||
|
func configWithExistedLeaseFile(t *testing.T) *ctrld.Config {
|
||||||
|
cfg := defaultConfig(t)
|
||||||
|
exe, err := os.Executable()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
cfg.Service.DHCPLeaseFile = exe
|
||||||
|
return cfg
|
||||||
|
}
|
||||||
|
|
||||||
|
func configWithInvalidLeaseFileFormat(t *testing.T) *ctrld.Config {
|
||||||
|
cfg := defaultConfig(t)
|
||||||
|
cfg.Service.DHCPLeaseFileFormat = "invalid"
|
||||||
|
return cfg
|
||||||
|
}
|
||||||
|
|
||||||
|
func configWithDhcp4KeaFormat(t *testing.T) *ctrld.Config {
|
||||||
|
cfg := defaultConfig(t)
|
||||||
|
cfg.Service.DHCPLeaseFileFormat = "kea-dhcp4"
|
||||||
|
return cfg
|
||||||
|
}
|
||||||
|
|
||||||
|
func configWithInvalidDoHEndpoint(t *testing.T) *ctrld.Config {
|
||||||
|
cfg := defaultConfig(t)
|
||||||
|
cfg.Upstream["0"].Endpoint = "/1.1.1.1"
|
||||||
|
cfg.Upstream["0"].Type = ctrld.ResolverTypeDOH
|
||||||
|
return cfg
|
||||||
|
}
|
||||||
|
|
||||||
|
func configWithInvalidClientIDPref(t *testing.T) *ctrld.Config {
|
||||||
|
cfg := defaultConfig(t)
|
||||||
|
cfg.Service.ClientIDPref = "foo"
|
||||||
|
return cfg
|
||||||
|
}
|
||||||
|
|
||||||
|
func configWithInvalidFlushCacheDomain(t *testing.T) *ctrld.Config {
|
||||||
|
cfg := defaultConfig(t)
|
||||||
|
cfg.Service.CacheFlushDomains = make([]string, 257)
|
||||||
|
for i := range cfg.Service.CacheFlushDomains {
|
||||||
|
cfg.Service.CacheFlushDomains[i] = fmt.Sprintf("%d.com", i)
|
||||||
|
}
|
||||||
|
return cfg
|
||||||
}
|
}
|
||||||
|
|||||||
7
desktop_darwin.go
Normal file
7
desktop_darwin.go
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
package ctrld
|
||||||
|
|
||||||
|
// IsDesktopPlatform indicates if ctrld is running on a desktop platform,
|
||||||
|
// currently defined as macOS or Windows workstation.
|
||||||
|
func IsDesktopPlatform() bool {
|
||||||
|
return true
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user