mirror of
https://github.com/netbirdio/netbird.git
synced 2026-05-08 09:49:54 +00:00
Compare commits
473 Commits
move-licen
...
dns-skip-f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c1862dba6c | ||
|
|
205ebcfda2 | ||
|
|
f23aaa9ae7 | ||
|
|
f532976e05 | ||
|
|
71a400f90f | ||
|
|
bfeb9b19ec | ||
|
|
b7742b15ec | ||
|
|
daa256b556 | ||
|
|
bea54ef6aa | ||
|
|
e394818b9f | ||
|
|
34c3c1a6f0 | ||
|
|
f21965cdd8 | ||
|
|
b19b7464ea | ||
|
|
cfb1b3fe31 | ||
|
|
3c28d29725 | ||
|
|
1795bc801d | ||
|
|
31395f8bd2 | ||
|
|
cd8e71002f | ||
|
|
97db824929 | ||
|
|
77a0992dc2 | ||
|
|
104990dfdd | ||
|
|
bde632c3b2 | ||
|
|
4268a5cfb7 | ||
|
|
a547fc74ed | ||
|
|
a21f6ecb0a | ||
|
|
6262b0d841 | ||
|
|
50b58a6828 | ||
|
|
057d651d2e | ||
|
|
c4b2da4c92 | ||
|
|
dcd1db42ef | ||
|
|
f29f5a0978 | ||
|
|
3fc5a8d4a1 | ||
|
|
57945fc328 | ||
|
|
ed828b7af4 | ||
|
|
11ac2af2f5 | ||
|
|
df197d5001 | ||
|
|
ad93dcf980 | ||
|
|
7eba5dafd8 | ||
|
|
28fe26637b | ||
|
|
407e9d304b | ||
|
|
e5474e199f | ||
|
|
db44848e2d | ||
|
|
9417ce3b3a | ||
|
|
8fc4265995 | ||
|
|
9c50819f20 | ||
|
|
6f0eff3ba0 | ||
|
|
f8745723fc | ||
|
|
154b81645a | ||
|
|
34167c8a16 | ||
|
|
d6f08e4840 | ||
|
|
f732b01a05 | ||
|
|
c07c726ea7 | ||
|
|
fa0d58d093 | ||
|
|
b6038e8acd | ||
|
|
5da05ecca6 | ||
|
|
801de8c68d | ||
|
|
a822a33240 | ||
|
|
57b23c5b25 | ||
|
|
1165058fad | ||
|
|
703353d354 | ||
|
|
2fb50aef6b | ||
|
|
eb3aa96257 | ||
|
|
064ec1c832 | ||
|
|
75e408f51c | ||
|
|
5a89e6621b | ||
|
|
06dfa9d4a5 | ||
|
|
45d9ee52c0 | ||
|
|
3098f48b25 | ||
|
|
7f023ce801 | ||
|
|
e361126515 | ||
|
|
95213f7157 | ||
|
|
2e0e3a3601 | ||
|
|
8ae8f2098f | ||
|
|
a39787d679 | ||
|
|
53b04e512a | ||
|
|
633dde8d1f | ||
|
|
7e4542adde | ||
|
|
d4c61ed38b | ||
|
|
6b540d145c | ||
|
|
08f624507d | ||
|
|
95bc01e48f | ||
|
|
0d86de47df | ||
|
|
e804a705b7 | ||
|
|
46fc8c9f65 | ||
|
|
d7ad908962 | ||
|
|
c5623307cc | ||
|
|
7f666b8022 | ||
|
|
0a30b9b275 | ||
|
|
4eed459f27 | ||
|
|
13539543af | ||
|
|
7483fec048 | ||
|
|
5259e5df51 | ||
|
|
ebd78e0122 | ||
|
|
cf86b9a528 | ||
|
|
ee588e1536 | ||
|
|
2a8aacc5c9 | ||
|
|
15709bc666 | ||
|
|
789b4113fe | ||
|
|
d2cdc0efec | ||
|
|
ee343d5d77 | ||
|
|
099c493b18 | ||
|
|
c1d1229ae0 | ||
|
|
94a36cb53e | ||
|
|
c7ba931466 | ||
|
|
413d95b740 | ||
|
|
332c624c55 | ||
|
|
dc160aff36 | ||
|
|
96806bf55f | ||
|
|
d33cd4c95b | ||
|
|
e2c2f64be7 | ||
|
|
cb73b94ffb | ||
|
|
1d920d700c | ||
|
|
bb85eee40a | ||
|
|
aba5d6f0d2 | ||
|
|
0588d2dbe1 | ||
|
|
14b3b77bda | ||
|
|
6da34e483c | ||
|
|
0efef671d7 | ||
|
|
435203b13b | ||
|
|
decb5dd3af | ||
|
|
28fbf96b2a | ||
|
|
9d1a37c644 | ||
|
|
5bf2372c4d | ||
|
|
c2c6396a04 | ||
|
|
aaf813fc0c | ||
|
|
d97fe84296 | ||
|
|
81f45dab21 | ||
|
|
d670e7382a | ||
|
|
cd8c686339 | ||
|
|
f5c41e3018 | ||
|
|
2477f99d89 | ||
|
|
940f530ac2 | ||
|
|
4d3e2f8ad3 | ||
|
|
5ae986e1c4 | ||
|
|
e5914e4e8b | ||
|
|
c238f5425f | ||
|
|
3c3097ea74 | ||
|
|
405c3f4003 | ||
|
|
6553ce4cea | ||
|
|
a62d472bc4 | ||
|
|
434ac7f0f5 | ||
|
|
7bbe71c3ac | ||
|
|
04dcaadabf | ||
|
|
c522506849 | ||
|
|
0765352c99 | ||
|
|
13807f1b3d | ||
|
|
c919ea149e | ||
|
|
be6fd119d8 | ||
|
|
7abf730d77 | ||
|
|
ec96c5ecaf | ||
|
|
7e1cce4b9f | ||
|
|
7be8752a00 | ||
|
|
145d82f322 | ||
|
|
a8b9570700 | ||
|
|
6ff6d84646 | ||
|
|
9aaa05e8ea | ||
|
|
0af5a0441f | ||
|
|
0fc63ea0ba | ||
|
|
0b329f7881 | ||
|
|
5b85edb753 | ||
|
|
17cfa5fe1e | ||
|
|
2313494e0e | ||
|
|
fd9d430334 | ||
|
|
91f0d5cefd | ||
|
|
82762280ee | ||
|
|
b550a2face | ||
|
|
ab77508950 | ||
|
|
b9462f5c6b | ||
|
|
5ffaa5cdd6 | ||
|
|
a1858a9cb7 | ||
|
|
212b34f639 | ||
|
|
af8eaa23e2 | ||
|
|
f0eed50678 | ||
|
|
19d94c6158 | ||
|
|
628eb56073 | ||
|
|
a590c38d8b | ||
|
|
4e149c9222 | ||
|
|
59f5b34280 | ||
|
|
dff06d0898 | ||
|
|
80a8816b1d | ||
|
|
387e374e4b | ||
|
|
3e6baea405 | ||
|
|
fe9b844511 | ||
|
|
2e1aa497d2 | ||
|
|
529c0314f8 | ||
|
|
d86875aeac | ||
|
|
f80fe506d5 | ||
|
|
967c6f3cd3 | ||
|
|
e50e124e70 | ||
|
|
c545689448 | ||
|
|
8f389fef19 | ||
|
|
d3d6a327e0 | ||
|
|
b5489d4986 | ||
|
|
7a23c57cf8 | ||
|
|
11f891220e | ||
|
|
5585adce18 | ||
|
|
f884299823 | ||
|
|
15aa6bae1b | ||
|
|
11eb725ac8 | ||
|
|
30c02ab78c | ||
|
|
3acd86e346 | ||
|
|
5c20f13c48 | ||
|
|
e6587b071d | ||
|
|
85451ab4cd | ||
|
|
a7f3ba03eb | ||
|
|
4f0a3a77ad | ||
|
|
44655ca9b5 | ||
|
|
e601278117 | ||
|
|
8e7b016be2 | ||
|
|
9e01ea7aae | ||
|
|
cfc7ec8bb9 | ||
|
|
b3bbc0e5c6 | ||
|
|
d7c8e37ff4 | ||
|
|
05b66e73bc | ||
|
|
01ceedac89 | ||
|
|
403babd433 | ||
|
|
47133031e5 | ||
|
|
82da606886 | ||
|
|
bbe5ae2145 | ||
|
|
0b21498b39 | ||
|
|
0ca59535f1 | ||
|
|
59c77d0658 | ||
|
|
333e045099 | ||
|
|
c2c4d9d336 | ||
|
|
9a6a72e88e | ||
|
|
afe6d9fca4 | ||
|
|
ef82905526 | ||
|
|
d18747e846 | ||
|
|
f341d69314 | ||
|
|
327142837c | ||
|
|
f8c0321aee | ||
|
|
89115ff76a | ||
|
|
63c83aa8d2 | ||
|
|
37f025c966 | ||
|
|
4a54f0d670 | ||
|
|
98890a29e3 | ||
|
|
9d123ec059 | ||
|
|
5d171f181a | ||
|
|
22f878b3b7 | ||
|
|
44ef1a18dd | ||
|
|
2b98dc4e52 | ||
|
|
2a26cb4567 | ||
|
|
5ca1b64328 | ||
|
|
36752a8cbb | ||
|
|
f117fc7509 | ||
|
|
fc6b93ae59 | ||
|
|
564fa4ab04 | ||
|
|
a6db88fbd2 | ||
|
|
4b5294e596 | ||
|
|
a322dce42a | ||
|
|
d1ead2265b | ||
|
|
bbca74476e | ||
|
|
318cf59d66 | ||
|
|
e9b2a6e808 | ||
|
|
2dbdb5c1a7 | ||
|
|
2cdab6d7b7 | ||
|
|
e49c0e8862 | ||
|
|
e7c84d0ead | ||
|
|
1c934cca64 | ||
|
|
4aff4a6424 | ||
|
|
1bd7190954 | ||
|
|
0146e39714 | ||
|
|
baed6e46ec | ||
|
|
0d1ffba75f | ||
|
|
1024d45698 | ||
|
|
e5d4947d60 | ||
|
|
cb9b39b950 | ||
|
|
68c481fa44 | ||
|
|
01a9cd4651 | ||
|
|
f53155562f | ||
|
|
edce11b34d | ||
|
|
841b2d26c6 | ||
|
|
d3eeb6d8ee | ||
|
|
7ebf37ef20 | ||
|
|
64b849c801 | ||
|
|
69d4b5d821 | ||
|
|
3dfa97dcbd | ||
|
|
1ddc9ce2bf | ||
|
|
2de1949018 | ||
|
|
fc88399c23 | ||
|
|
6981fdce7e | ||
|
|
08403f64aa | ||
|
|
391221a986 | ||
|
|
7bc85107eb | ||
|
|
3be16d19a0 | ||
|
|
af8f730bda | ||
|
|
c3f176f348 | ||
|
|
0119f3e9f4 | ||
|
|
1b96648d4d | ||
|
|
d2f9653cea | ||
|
|
194a986926 | ||
|
|
f7732557fa | ||
|
|
d488f58311 | ||
|
|
6fdc00ff41 | ||
|
|
b20d484972 | ||
|
|
8931293343 | ||
|
|
7b830d8f72 | ||
|
|
3a0cf230a1 | ||
|
|
0c990ab662 | ||
|
|
101c813e98 | ||
|
|
5333e55a81 | ||
|
|
81c11df103 | ||
|
|
f74bc48d16 | ||
|
|
0169e4540f | ||
|
|
cead3f38ee | ||
|
|
b55262d4a2 | ||
|
|
2248ff392f | ||
|
|
06966da012 | ||
|
|
d4f7df271a | ||
|
|
5299549eb6 | ||
|
|
7d791620a6 | ||
|
|
44ab454a13 | ||
|
|
11f50d6c38 | ||
|
|
05af39a69b | ||
|
|
074df56c3d | ||
|
|
2381e216e4 | ||
|
|
ded04b7627 | ||
|
|
67211010f7 | ||
|
|
c61568ceb4 | ||
|
|
737d6061bf | ||
|
|
ee3a67d2d8 | ||
|
|
1a32e4c223 | ||
|
|
269d5d1cba | ||
|
|
a1de2b8a98 | ||
|
|
d0221a3e72 | ||
|
|
8da23daae3 | ||
|
|
f86022eace | ||
|
|
ee54827f94 | ||
|
|
e908dea702 | ||
|
|
030650a905 | ||
|
|
e01998815e | ||
|
|
07e4a5a23c | ||
|
|
b3a2992a10 | ||
|
|
202fa47f2b | ||
|
|
4888021ba6 | ||
|
|
a0b0b664b6 | ||
|
|
50da5074e7 | ||
|
|
58daa674ef | ||
|
|
245481f33b | ||
|
|
b352ab84c0 | ||
|
|
3ce5d6a4f8 | ||
|
|
4c2eb2af73 | ||
|
|
daf1449174 | ||
|
|
1ff7abe909 | ||
|
|
067c77e49e | ||
|
|
291e640b28 | ||
|
|
efb954b7d6 | ||
|
|
cac9326d3d | ||
|
|
520d9c66cf | ||
|
|
ff10498a8b | ||
|
|
00b747ad5d | ||
|
|
d9118eb239 | ||
|
|
94de656fae | ||
|
|
37abab8b69 | ||
|
|
b12c084a50 | ||
|
|
394ad19507 | ||
|
|
614e7d5b90 | ||
|
|
f7967f9ae3 | ||
|
|
684fc0d2a2 | ||
|
|
0ad0c81899 | ||
|
|
e8863fbb55 | ||
|
|
9c9d8e17d7 | ||
|
|
fb71b0d04b | ||
|
|
ab7d6b2196 | ||
|
|
9c5b2575e3 | ||
|
|
00e2689ffb | ||
|
|
cf535f8c61 | ||
|
|
24df442198 | ||
|
|
8722b79799 | ||
|
|
afcdef6121 | ||
|
|
12a7fa24d7 | ||
|
|
6ff9aa0366 | ||
|
|
e586c20e36 | ||
|
|
5393ad948f | ||
|
|
20d6beff1b | ||
|
|
d35b7d675c | ||
|
|
f012fb8592 | ||
|
|
7142d45ef3 | ||
|
|
9bd578d4ea | ||
|
|
f022e34287 | ||
|
|
7bb4fc3450 | ||
|
|
07856f516c | ||
|
|
08b782d6ba | ||
|
|
80a312cc9c | ||
|
|
9ba067391f | ||
|
|
7ac65bf1ad | ||
|
|
2e9c316852 | ||
|
|
96cdd56902 | ||
|
|
9ed1437442 | ||
|
|
a8604ef51c | ||
|
|
d88e046d00 | ||
|
|
1d2c7776fd | ||
|
|
4035f07248 | ||
|
|
ef2721f4e1 | ||
|
|
e11970e32e | ||
|
|
38f9d5ed58 | ||
|
|
b6a327e0c9 | ||
|
|
67f7b2404e | ||
|
|
73201c4f3e | ||
|
|
33d1761fe8 | ||
|
|
aa914a0f26 | ||
|
|
ab6a9e85de | ||
|
|
d3b123c76d | ||
|
|
fc4932a23f | ||
|
|
b7e98acd1f | ||
|
|
433bc4ead9 | ||
|
|
011cc81678 | ||
|
|
537151e0f3 | ||
|
|
a9c28ef723 | ||
|
|
c29bb1a289 | ||
|
|
447cd287f5 | ||
|
|
5748bdd64e | ||
|
|
08f31fbcb3 | ||
|
|
932c02eaab | ||
|
|
abcbde26f9 | ||
|
|
90e3b8009f | ||
|
|
94d34dc0c5 | ||
|
|
44851e06fb | ||
|
|
3f4f825ec1 | ||
|
|
f538e6e9ae | ||
|
|
cb6b086164 | ||
|
|
71b6855e09 | ||
|
|
9bdc4908fb | ||
|
|
031ab11178 | ||
|
|
d2e48d4f5e | ||
|
|
27dd97c9c4 | ||
|
|
e87b4ace11 | ||
|
|
a232cf614c | ||
|
|
a293f760af | ||
|
|
10e9cf8c62 | ||
|
|
7193bd2da7 | ||
|
|
52948ccd61 | ||
|
|
4b77359042 | ||
|
|
387d43bcc1 | ||
|
|
e47d815dd2 | ||
|
|
cb83b7c0d3 | ||
|
|
ddcd182859 | ||
|
|
aca0398105 | ||
|
|
02200d790b | ||
|
|
f31bba87b4 | ||
|
|
7285fef0f0 | ||
|
|
20973063d8 | ||
|
|
ba2e9b6d88 | ||
|
|
131d7a3694 | ||
|
|
290fe2d8b9 | ||
|
|
7fb1a2fe31 | ||
|
|
32146e576d | ||
|
|
1311364397 | ||
|
|
68f56b797d | ||
|
|
3351b38434 | ||
|
|
05cbead39b | ||
|
|
60f4d5f9b0 | ||
|
|
4eeb2d8deb | ||
|
|
d71a82769c | ||
|
|
0d79301141 | ||
|
|
e4b41d0ad7 | ||
|
|
9cc9462cd5 | ||
|
|
3176b53968 | ||
|
|
27957036c9 | ||
|
|
6fb568728f | ||
|
|
cc97cffff1 | ||
|
|
c28275611b | ||
|
|
56f169eede | ||
|
|
07cf9d5895 | ||
|
|
7df49e249d | ||
|
|
dbfc8a52c9 | ||
|
|
98ddac07bf | ||
|
|
48475ddc05 | ||
|
|
6aa4ba7af4 | ||
|
|
2e16c9914a | ||
|
|
5c29d395b2 | ||
|
|
229e0038ee | ||
|
|
75327d9519 |
@@ -1,15 +1,15 @@
|
|||||||
FROM golang:1.23-bullseye
|
FROM golang:1.25-bookworm
|
||||||
|
|
||||||
RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \
|
RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \
|
||||||
&& apt-get -y install --no-install-recommends\
|
&& apt-get -y install --no-install-recommends\
|
||||||
gettext-base=0.21-4 \
|
gettext-base=0.21-12 \
|
||||||
iptables=1.8.7-1 \
|
iptables=1.8.9-2 \
|
||||||
libgl1-mesa-dev=20.3.5-1 \
|
libgl1-mesa-dev=22.3.6-1+deb12u1 \
|
||||||
xorg-dev=1:7.7+22 \
|
xorg-dev=1:7.7+23 \
|
||||||
libayatana-appindicator3-dev=0.5.5-2+deb11u2 \
|
libayatana-appindicator3-dev=0.5.92-1 \
|
||||||
&& apt-get clean \
|
&& apt-get clean \
|
||||||
&& rm -rf /var/lib/apt/lists/* \
|
&& rm -rf /var/lib/apt/lists/* \
|
||||||
&& go install -v golang.org/x/tools/gopls@v0.18.1
|
&& go install -v golang.org/x/tools/gopls@latest
|
||||||
|
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|||||||
6
.dockerignore
Normal file
6
.dockerignore
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
*.pem
|
||||||
|
*.key
|
||||||
|
*.crt
|
||||||
|
*.p12
|
||||||
11
.githooks/pre-push
Executable file
11
.githooks/pre-push
Executable file
@@ -0,0 +1,11 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
echo "Running pre-push hook..."
|
||||||
|
if ! make lint; then
|
||||||
|
echo ""
|
||||||
|
echo "Hint: To push without verification, run:"
|
||||||
|
echo " git push --no-verify"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "All checks passed!"
|
||||||
130
.github/DISCUSSION_TEMPLATE/ideas-feature-requests.yml
vendored
Normal file
130
.github/DISCUSSION_TEMPLATE/ideas-feature-requests.yml
vendored
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
## Ideas & Feature Requests
|
||||||
|
|
||||||
|
Use this category for feature requests, enhancements, integrations, and product ideas.
|
||||||
|
|
||||||
|
NetBird uses community traction in discussions — upvotes, replies, affected users, and use-case detail — as an input when deciding what should become a maintainer-curated issue or roadmap item. A clear problem statement is more useful than a solution-only request.
|
||||||
|
|
||||||
|
Please search first and add your use case to an existing discussion when one already exists.
|
||||||
|
|
||||||
|
- type: checkboxes
|
||||||
|
id: preflight
|
||||||
|
attributes:
|
||||||
|
label: Before posting
|
||||||
|
options:
|
||||||
|
- label: I searched existing discussions and issues for similar requests.
|
||||||
|
required: true
|
||||||
|
- label: I checked the documentation to confirm this is not already supported.
|
||||||
|
required: true
|
||||||
|
- label: This is a product idea or enhancement request, not a support question.
|
||||||
|
required: true
|
||||||
|
- label: I removed or anonymized sensitive details from examples and screenshots.
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: dropdown
|
||||||
|
id: area
|
||||||
|
attributes:
|
||||||
|
label: Product area
|
||||||
|
description: Select every area this request touches.
|
||||||
|
multiple: true
|
||||||
|
options:
|
||||||
|
- Client / Agent
|
||||||
|
- CLI
|
||||||
|
- Desktop UI
|
||||||
|
- Mobile app
|
||||||
|
- Dashboard / Admin UI
|
||||||
|
- Management service / API
|
||||||
|
- Signal service
|
||||||
|
- Relay
|
||||||
|
- DNS
|
||||||
|
- Routes / Exit nodes
|
||||||
|
- NetBird SSH
|
||||||
|
- Access control policies
|
||||||
|
- Posture checks
|
||||||
|
- Identity provider / SSO
|
||||||
|
- Self-hosting / Deployment
|
||||||
|
- Kubernetes / Operator
|
||||||
|
- Terraform / Automation
|
||||||
|
- Documentation
|
||||||
|
- Other / not sure
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: problem
|
||||||
|
attributes:
|
||||||
|
label: Problem or use case
|
||||||
|
description: What are you trying to accomplish, and what is difficult or impossible today?
|
||||||
|
placeholder: |
|
||||||
|
As a ...
|
||||||
|
I want to ...
|
||||||
|
Because ...
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: proposal
|
||||||
|
attributes:
|
||||||
|
label: Proposed solution
|
||||||
|
description: Describe the behavior, workflow, API, UI, or integration you would like to see.
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: alternatives
|
||||||
|
attributes:
|
||||||
|
label: Alternatives or workarounds considered
|
||||||
|
description: What have you tried today? Why is the current workaround not enough?
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: impact
|
||||||
|
attributes:
|
||||||
|
label: Community impact and priority
|
||||||
|
description: Help us understand who benefits and how urgent this is.
|
||||||
|
placeholder: |
|
||||||
|
- Number of users/teams/peers affected:
|
||||||
|
- Deployment type: Cloud / self-hosted / both
|
||||||
|
- Frequency: daily / weekly / occasional
|
||||||
|
- Blocking production adoption? yes/no
|
||||||
|
- Related comments, discussions, or customer requests:
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: examples
|
||||||
|
attributes:
|
||||||
|
label: Examples from other tools or products
|
||||||
|
description: If another tool solves this well, link or describe the behavior.
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: security
|
||||||
|
attributes:
|
||||||
|
label: Security, privacy, and compatibility considerations
|
||||||
|
description: Note any access-control, audit, data retention, network, platform, or backward-compatibility concerns.
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: implementation
|
||||||
|
attributes:
|
||||||
|
label: Implementation ideas
|
||||||
|
description: Optional. If you are familiar with the codebase or API, share possible implementation notes.
|
||||||
|
|
||||||
|
- type: dropdown
|
||||||
|
id: contribution
|
||||||
|
attributes:
|
||||||
|
label: Are you willing to help?
|
||||||
|
options:
|
||||||
|
- Yes, I can submit a PR if the approach is accepted.
|
||||||
|
- Yes, I can test or validate a proposed implementation.
|
||||||
|
- Yes, I can provide more use-case details.
|
||||||
|
- Not at this time.
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: additional-context
|
||||||
|
attributes:
|
||||||
|
label: Additional context
|
||||||
|
description: Add screenshots, diagrams, links, or anything else that helps explain the request.
|
||||||
237
.github/DISCUSSION_TEMPLATE/issue-triage.yml
vendored
Normal file
237
.github/DISCUSSION_TEMPLATE/issue-triage.yml
vendored
Normal file
@@ -0,0 +1,237 @@
|
|||||||
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
## Issue Triage
|
||||||
|
|
||||||
|
Use this category for reproducible bugs and regressions in NetBird.
|
||||||
|
|
||||||
|
The more context you include, the faster we can validate and act on your report. If you're not sure whether something is a bug, **Q&A / Support** is a good starting point — we can always move the conversation here once we've confirmed it's a product issue.
|
||||||
|
|
||||||
|
Intermittent issues are useful too. Include the trigger, frequency, timing, and any logs or debug evidence you have, and we'll work from there.
|
||||||
|
|
||||||
|
Please don't include secrets, tokens, private keys, internal hostnames, or public IPs. Security vulnerabilities should be reported through the repository security policy rather than a public discussion.
|
||||||
|
|
||||||
|
- type: checkboxes
|
||||||
|
id: preflight
|
||||||
|
attributes:
|
||||||
|
label: Before posting
|
||||||
|
options:
|
||||||
|
- label: I searched existing discussions and issues, including closed ones, and checked the relevant docs.
|
||||||
|
required: true
|
||||||
|
- label: I believe this is a product bug rather than a configuration or setup question.
|
||||||
|
required: true
|
||||||
|
- label: I can reproduce this issue, or for intermittent issues I've included trigger, frequency, and timing details below.
|
||||||
|
required: true
|
||||||
|
- label: I removed or anonymized sensitive data from logs, screenshots, and configuration.
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: dropdown
|
||||||
|
id: area
|
||||||
|
attributes:
|
||||||
|
label: Affected area
|
||||||
|
description: Select every area this report touches.
|
||||||
|
multiple: true
|
||||||
|
options:
|
||||||
|
- Client / Agent
|
||||||
|
- Reverse Proxy
|
||||||
|
- CLI
|
||||||
|
- Desktop UI
|
||||||
|
- Mobile app
|
||||||
|
- Peer connectivity
|
||||||
|
- DNS
|
||||||
|
- Routes / Exit nodes
|
||||||
|
- NetBird SSH
|
||||||
|
- Relay / Signal / NAT traversal
|
||||||
|
- Login / Authentication / IdP
|
||||||
|
- Dashboard / Admin UI
|
||||||
|
- Management service / API
|
||||||
|
- Access control policies / Posture checks
|
||||||
|
- Self-hosting / Deployment
|
||||||
|
- Kubernetes / Operator
|
||||||
|
- Documentation
|
||||||
|
- Other / not sure
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: dropdown
|
||||||
|
id: deployment
|
||||||
|
attributes:
|
||||||
|
label: Deployment type
|
||||||
|
options:
|
||||||
|
- NetBird Cloud
|
||||||
|
- Self-hosted - quickstart script
|
||||||
|
- Self-hosted - advanced/custom deployment
|
||||||
|
- Local development build
|
||||||
|
- Not sure / environment I do not fully control
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: dropdown
|
||||||
|
id: platform
|
||||||
|
attributes:
|
||||||
|
label: Operating system or environment
|
||||||
|
description: Select every environment involved in the reproduction.
|
||||||
|
multiple: true
|
||||||
|
options:
|
||||||
|
- Linux
|
||||||
|
- macOS
|
||||||
|
- Windows
|
||||||
|
- Android
|
||||||
|
- iOS
|
||||||
|
- FreeBSD
|
||||||
|
- OpenWRT
|
||||||
|
- Docker
|
||||||
|
- Kubernetes
|
||||||
|
- Synology
|
||||||
|
- Browser
|
||||||
|
- Other / not sure
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: version
|
||||||
|
attributes:
|
||||||
|
label: NetBird version and upgrade status
|
||||||
|
description: Run `netbird version` where applicable. For self-hosted deployments, include management, signal, relay, and dashboard versions if available. If you cannot test on a current/supported version, explain why.
|
||||||
|
placeholder: |
|
||||||
|
Example:
|
||||||
|
- Client: 0.30.2
|
||||||
|
- Management: 0.30.2
|
||||||
|
- Signal: 0.30.2
|
||||||
|
- Relay: 0.30.2
|
||||||
|
- Dashboard: 0.30.2
|
||||||
|
- Upgrade status: reproduced on current version / cannot upgrade because ...
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: dropdown
|
||||||
|
id: regression
|
||||||
|
attributes:
|
||||||
|
label: Did this work before?
|
||||||
|
options:
|
||||||
|
- Yes, this worked before
|
||||||
|
- No, this never worked
|
||||||
|
- Not sure
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: regression-details
|
||||||
|
attributes:
|
||||||
|
label: Regression details
|
||||||
|
description: If this worked before, include the last known working version, first known broken version, and any recent upgrade, configuration, network, or IdP changes.
|
||||||
|
placeholder: |
|
||||||
|
- Last known working version:
|
||||||
|
- First known broken version:
|
||||||
|
- Recent changes:
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: summary
|
||||||
|
attributes:
|
||||||
|
label: Summary
|
||||||
|
description: Briefly describe the reproducible bug.
|
||||||
|
placeholder: What is broken?
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: current-behavior
|
||||||
|
attributes:
|
||||||
|
label: Current behavior
|
||||||
|
description: What happens now? Include exact errors, timeouts, UI messages, or failed commands when possible.
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: expected-behavior
|
||||||
|
attributes:
|
||||||
|
label: Expected behavior
|
||||||
|
description: What did you expect to happen instead?
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: reproduction
|
||||||
|
attributes:
|
||||||
|
label: Steps to reproduce
|
||||||
|
description: Provide the smallest set of steps that reliably reproduces the bug. If the issue is intermittent, include the trigger, frequency, timing, and relevant timestamps.
|
||||||
|
placeholder: |
|
||||||
|
1. Configure ...
|
||||||
|
2. Run ...
|
||||||
|
3. Observe ...
|
||||||
|
|
||||||
|
For intermittent issues:
|
||||||
|
- Trigger:
|
||||||
|
- Frequency:
|
||||||
|
- Timing/timestamps:
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: environment
|
||||||
|
attributes:
|
||||||
|
label: Environment and topology
|
||||||
|
description: Include the relevant topology and software involved in the reproduction. For UI/docs-only reports, write `N/A` if this does not apply. Use `None`, `Unknown`, or `N/A` where appropriate.
|
||||||
|
placeholder: |
|
||||||
|
- Peer A:
|
||||||
|
- Peer B:
|
||||||
|
- Same LAN or different networks:
|
||||||
|
- NAT/CGNAT/corporate firewall/mobile network:
|
||||||
|
- Other VPN software:
|
||||||
|
- Firewall, DNS, or endpoint security software:
|
||||||
|
- Routes, DNS, policies, posture checks, or SSH rules involved:
|
||||||
|
- IdP, reverse proxy, or browser involved:
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: self-hosted-details
|
||||||
|
attributes:
|
||||||
|
label: Self-hosted details, if available
|
||||||
|
description: Optional. If you use self-hosting and have access to these details, include them. If you do not administer the environment, provide what you know and say what you cannot access.
|
||||||
|
placeholder: |
|
||||||
|
- Deployment method: quickstart / Docker Compose / Helm / operator / custom
|
||||||
|
- Management/signal/relay/dashboard versions:
|
||||||
|
- Reverse proxy:
|
||||||
|
- IdP/provider:
|
||||||
|
- STUN/TURN/coturn/relay details:
|
||||||
|
- Relevant component logs:
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: logs
|
||||||
|
attributes:
|
||||||
|
label: Logs, status output, or debug evidence
|
||||||
|
description: |
|
||||||
|
For client, connectivity, DNS, route, relay/signal, or self-hosted reports, logs are essential — please include anonymized output from `netbird status -dA`, or a debug bundle via `netbird debug for 1m -AS -U`. Debug bundles are automatically deleted after 30 days.
|
||||||
|
|
||||||
|
For UI, dashboard, or documentation reports, leave the pre-filled `N/A`.
|
||||||
|
value: "N/A"
|
||||||
|
render: shell
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: related-reports
|
||||||
|
attributes:
|
||||||
|
label: Related issues or discussions
|
||||||
|
description: Optional. Link similar reports you found while searching, if any.
|
||||||
|
placeholder: |
|
||||||
|
- Related issue/discussion:
|
||||||
|
- Why this may be the same or different:
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: impact
|
||||||
|
attributes:
|
||||||
|
label: Impact
|
||||||
|
description: Optional. Help us understand priority. How many users, peers, environments, or workflows are affected? Is there a workaround?
|
||||||
|
placeholder: |
|
||||||
|
- Affected users/peers:
|
||||||
|
- Business or production impact:
|
||||||
|
- Workaround available:
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: additional-context
|
||||||
|
attributes:
|
||||||
|
label: Additional context
|
||||||
|
description: Add links to related discussions, issues, docs, screenshots, recordings, or anything else that may help validation.
|
||||||
146
.github/DISCUSSION_TEMPLATE/q-a-support.yml
vendored
Normal file
146
.github/DISCUSSION_TEMPLATE/q-a-support.yml
vendored
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
## Q&A / Support
|
||||||
|
|
||||||
|
Use this category for questions about configuration, setup, self-hosted deployments, troubleshooting, and general NetBird usage.
|
||||||
|
|
||||||
|
This is community support and does not provide an SLA. For NetBird Cloud support, use the official support channel linked from the issue creation page. Please do not post secrets, tokens, private keys, internal hostnames, or public IPs unless you intentionally want them public.
|
||||||
|
|
||||||
|
If your question turns into a reproducible product defect, DevRel or a maintainer may ask you to open or move the conversation to Issue Triage.
|
||||||
|
|
||||||
|
- type: checkboxes
|
||||||
|
id: preflight
|
||||||
|
attributes:
|
||||||
|
label: Before posting
|
||||||
|
options:
|
||||||
|
- label: I searched existing discussions and issues for similar questions.
|
||||||
|
required: true
|
||||||
|
- label: I reviewed the relevant NetBird documentation or troubleshooting guide.
|
||||||
|
required: true
|
||||||
|
- label: I removed or anonymized sensitive data from logs, screenshots, and configuration.
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: dropdown
|
||||||
|
id: topic
|
||||||
|
attributes:
|
||||||
|
label: Topic
|
||||||
|
multiple: true
|
||||||
|
options:
|
||||||
|
- Getting started
|
||||||
|
- Self-hosting
|
||||||
|
- Client / Agent
|
||||||
|
- CLI
|
||||||
|
- Desktop UI
|
||||||
|
- Mobile app
|
||||||
|
- Dashboard / Admin UI
|
||||||
|
- DNS
|
||||||
|
- Routes / Exit nodes
|
||||||
|
- NetBird SSH
|
||||||
|
- Relay
|
||||||
|
- Access control policies
|
||||||
|
- Posture checks
|
||||||
|
- Identity provider / SSO
|
||||||
|
- API
|
||||||
|
- Kubernetes / Operator
|
||||||
|
- Terraform / Automation
|
||||||
|
- Documentation
|
||||||
|
- Other / not sure
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: dropdown
|
||||||
|
id: deployment
|
||||||
|
attributes:
|
||||||
|
label: Deployment type
|
||||||
|
options:
|
||||||
|
- NetBird Cloud
|
||||||
|
- Self-hosted - quickstart script
|
||||||
|
- Self-hosted - advanced/custom deployment
|
||||||
|
- Local development build
|
||||||
|
- Not sure
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: dropdown
|
||||||
|
id: platform
|
||||||
|
attributes:
|
||||||
|
label: Operating system or environment
|
||||||
|
multiple: true
|
||||||
|
options:
|
||||||
|
- Linux
|
||||||
|
- macOS
|
||||||
|
- Windows
|
||||||
|
- Android
|
||||||
|
- iOS
|
||||||
|
- FreeBSD
|
||||||
|
- OpenWRT
|
||||||
|
- Docker
|
||||||
|
- Kubernetes
|
||||||
|
- Synology
|
||||||
|
- Browser
|
||||||
|
- Other / not sure
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: input
|
||||||
|
id: version
|
||||||
|
attributes:
|
||||||
|
label: NetBird version
|
||||||
|
description: Run `netbird version` where applicable. For self-hosted deployments, include component versions if relevant.
|
||||||
|
placeholder: "Example: client 0.30.2, management 0.30.2"
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: question
|
||||||
|
attributes:
|
||||||
|
label: Question
|
||||||
|
description: What are you trying to understand or accomplish?
|
||||||
|
placeholder: Describe your question clearly.
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: goal
|
||||||
|
attributes:
|
||||||
|
label: Desired outcome
|
||||||
|
description: What would a successful answer help you do?
|
||||||
|
placeholder: |
|
||||||
|
I want to configure ...
|
||||||
|
I expected ...
|
||||||
|
I need help deciding ...
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: attempted
|
||||||
|
attributes:
|
||||||
|
label: What have you tried?
|
||||||
|
description: Include commands, documentation links, configuration attempts, or troubleshooting steps already tried.
|
||||||
|
placeholder: |
|
||||||
|
- Read ...
|
||||||
|
- Ran ...
|
||||||
|
- Changed ...
|
||||||
|
- Observed ...
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: environment
|
||||||
|
attributes:
|
||||||
|
label: Relevant environment details
|
||||||
|
description: Include redacted topology, IdP/provider, reverse proxy, firewall, DNS, route, policy, or self-hosted setup details that may affect the answer.
|
||||||
|
placeholder: |
|
||||||
|
- Deployment:
|
||||||
|
- Components involved:
|
||||||
|
- Network/topology:
|
||||||
|
- Related config:
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: logs
|
||||||
|
attributes:
|
||||||
|
label: Logs or output
|
||||||
|
description: Optional. Include anonymized logs, command output, screenshots, or `netbird status -dA` if relevant.
|
||||||
|
render: shell
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: additional-context
|
||||||
|
attributes:
|
||||||
|
label: Additional context
|
||||||
|
description: Add links, diagrams, screenshots, or other details that may help the community answer.
|
||||||
71
.github/ISSUE_TEMPLATE/bug-issue-report.md
vendored
71
.github/ISSUE_TEMPLATE/bug-issue-report.md
vendored
@@ -1,71 +0,0 @@
|
|||||||
---
|
|
||||||
name: Bug/Issue report
|
|
||||||
about: Create a report to help us improve
|
|
||||||
title: ''
|
|
||||||
labels: ['triage-needed']
|
|
||||||
assignees: ''
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Describe the problem**
|
|
||||||
|
|
||||||
A clear and concise description of what the problem is.
|
|
||||||
|
|
||||||
**To Reproduce**
|
|
||||||
|
|
||||||
Steps to reproduce the behavior:
|
|
||||||
1. Go to '...'
|
|
||||||
2. Click on '....'
|
|
||||||
3. Scroll down to '....'
|
|
||||||
4. See error
|
|
||||||
|
|
||||||
**Expected behavior**
|
|
||||||
|
|
||||||
A clear and concise description of what you expected to happen.
|
|
||||||
|
|
||||||
**Are you using NetBird Cloud?**
|
|
||||||
|
|
||||||
Please specify whether you use NetBird Cloud or self-host NetBird's control plane.
|
|
||||||
|
|
||||||
**NetBird version**
|
|
||||||
|
|
||||||
`netbird version`
|
|
||||||
|
|
||||||
**Is any other VPN software installed?**
|
|
||||||
|
|
||||||
If yes, which one?
|
|
||||||
|
|
||||||
**Debug output**
|
|
||||||
|
|
||||||
To help us resolve the problem, please attach the following anonymized status output
|
|
||||||
|
|
||||||
netbird status -dA
|
|
||||||
|
|
||||||
Create and upload a debug bundle, and share the returned file key:
|
|
||||||
|
|
||||||
netbird debug for 1m -AS -U
|
|
||||||
|
|
||||||
*Uploaded files are automatically deleted after 30 days.*
|
|
||||||
|
|
||||||
|
|
||||||
Alternatively, create the file only and attach it here manually:
|
|
||||||
|
|
||||||
netbird debug for 1m -AS
|
|
||||||
|
|
||||||
|
|
||||||
**Screenshots**
|
|
||||||
|
|
||||||
If applicable, add screenshots to help explain your problem.
|
|
||||||
|
|
||||||
**Additional context**
|
|
||||||
|
|
||||||
Add any other context about the problem here.
|
|
||||||
|
|
||||||
**Have you tried these troubleshooting steps?**
|
|
||||||
- [ ] Reviewed [client troubleshooting](https://docs.netbird.io/how-to/troubleshooting-client) (if applicable)
|
|
||||||
- [ ] Checked for newer NetBird versions
|
|
||||||
- [ ] Searched for similar issues on GitHub (including closed ones)
|
|
||||||
- [ ] Restarted the NetBird client
|
|
||||||
- [ ] Disabled other VPN software
|
|
||||||
- [ ] Checked firewall settings
|
|
||||||
|
|
||||||
26
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
26
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
blank_issues_enabled: false
|
||||||
|
contact_links:
|
||||||
|
- name: Start an Issue Triage discussion
|
||||||
|
url: https://github.com/netbirdio/netbird/discussions/new?category=issue-triage
|
||||||
|
about: Report a bug, regression, or unexpected behavior so DevRel can validate it before it becomes an issue.
|
||||||
|
- name: Propose an idea or feature request
|
||||||
|
url: https://github.com/netbirdio/netbird/discussions/new?category=ideas-feature-requests
|
||||||
|
about: Share feature requests, enhancements, and integration ideas for community feedback and prioritization.
|
||||||
|
- name: Ask a Q&A / Support question
|
||||||
|
url: https://github.com/netbirdio/netbird/discussions/new?category=q-a-support
|
||||||
|
about: Get help with setup, configuration, self-hosting, troubleshooting, and general usage.
|
||||||
|
- name: Security vulnerability disclosure
|
||||||
|
url: https://github.com/netbirdio/netbird/security/policy
|
||||||
|
about: Please do not report security vulnerabilities in public issues or discussions.
|
||||||
|
- name: Community Support Forum
|
||||||
|
url: https://forum.netbird.io/
|
||||||
|
about: Community support forum.
|
||||||
|
- name: Cloud Support
|
||||||
|
url: https://docs.netbird.io/help/report-bug-issues
|
||||||
|
about: Contact NetBird for Cloud support.
|
||||||
|
- name: Client / Connection Troubleshooting
|
||||||
|
url: https://docs.netbird.io/help/troubleshooting-client
|
||||||
|
about: See the client troubleshooting guide for common connectivity issues.
|
||||||
|
- name: Self-host Troubleshooting
|
||||||
|
url: https://docs.netbird.io/selfhosted/troubleshooting
|
||||||
|
about: See the self-host troubleshooting guide for common deployment issues.
|
||||||
20
.github/ISSUE_TEMPLATE/feature_request.md
vendored
20
.github/ISSUE_TEMPLATE/feature_request.md
vendored
@@ -1,20 +0,0 @@
|
|||||||
---
|
|
||||||
name: Feature request
|
|
||||||
about: Suggest an idea for this project
|
|
||||||
title: ''
|
|
||||||
labels: ['feature-request']
|
|
||||||
assignees: ''
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Is your feature request related to a problem? Please describe.**
|
|
||||||
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
|
|
||||||
|
|
||||||
**Describe the solution you'd like**
|
|
||||||
A clear and concise description of what you want to happen.
|
|
||||||
|
|
||||||
**Describe alternatives you've considered**
|
|
||||||
A clear and concise description of any alternative solutions or features you've considered.
|
|
||||||
|
|
||||||
**Additional context**
|
|
||||||
Add any other context or screenshots about the feature request here.
|
|
||||||
128
.github/ISSUE_TEMPLATE/validated_issue.yml
vendored
Normal file
128
.github/ISSUE_TEMPLATE/validated_issue.yml
vendored
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
name: Validated issue
|
||||||
|
description: Maintainer/DevRel only. Create an issue after a discussion has been validated or for internally validated work.
|
||||||
|
title: "[Validated]: "
|
||||||
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
## Discussion-first issue policy
|
||||||
|
|
||||||
|
Issues are maintainer-curated work items. Community reports and feature requests should start in [Discussions](https://github.com/netbirdio/netbird/discussions) so DevRel can validate, reproduce, and route them before engineering time is committed.
|
||||||
|
|
||||||
|
Use this form when:
|
||||||
|
- A discussion has been validated and should become actionable work.
|
||||||
|
- A maintainer is opening internally validated work that can bypass the discussion-first flow.
|
||||||
|
|
||||||
|
Issues opened without a relevant validated discussion or maintainer context may be closed and redirected to Discussions.
|
||||||
|
|
||||||
|
- type: checkboxes
|
||||||
|
id: validation-checks
|
||||||
|
attributes:
|
||||||
|
label: Validation checklist
|
||||||
|
options:
|
||||||
|
- label: This issue is linked to a validated discussion, or it is being opened directly by a maintainer.
|
||||||
|
required: true
|
||||||
|
- label: The report has enough context for engineering to act on it without re-triaging from scratch.
|
||||||
|
required: true
|
||||||
|
- label: Sensitive data, secrets, private keys, internal hostnames, and public IPs have been removed or intentionally disclosed.
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: dropdown
|
||||||
|
id: issue-type
|
||||||
|
attributes:
|
||||||
|
label: Issue type
|
||||||
|
options:
|
||||||
|
- Bug / Regression
|
||||||
|
- Feature / Enhancement
|
||||||
|
- Documentation
|
||||||
|
- Maintenance / Refactor
|
||||||
|
- Cross-repository coordination
|
||||||
|
- Other
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: input
|
||||||
|
id: source-discussion
|
||||||
|
attributes:
|
||||||
|
label: Source discussion
|
||||||
|
description: Link the GitHub Discussion that was validated. Maintainers bypassing the flow can write "Maintainer-created" and explain why below.
|
||||||
|
placeholder: https://github.com/netbirdio/netbird/discussions/1234
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: input
|
||||||
|
id: validation-owner
|
||||||
|
attributes:
|
||||||
|
label: Validation owner
|
||||||
|
description: GitHub handle of the DevRel team member or maintainer who validated this work.
|
||||||
|
placeholder: "@username"
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: dropdown
|
||||||
|
id: target-repository
|
||||||
|
attributes:
|
||||||
|
label: Target repository
|
||||||
|
description: Where should the implementation work happen?
|
||||||
|
options:
|
||||||
|
- netbirdio/netbird
|
||||||
|
- netbirdio/dashboard
|
||||||
|
- netbirdio/kubernetes-operator
|
||||||
|
- netbirdio/docs
|
||||||
|
- Multiple repositories
|
||||||
|
- Unknown / needs routing
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: summary
|
||||||
|
attributes:
|
||||||
|
label: Summary
|
||||||
|
description: Concise description of the validated work.
|
||||||
|
placeholder: What needs to be fixed, changed, documented, or built?
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: evidence
|
||||||
|
attributes:
|
||||||
|
label: Validation evidence
|
||||||
|
description: For bugs, include reproduction status, affected versions, logs, and environment. For features, include community traction, affected users, and alignment notes.
|
||||||
|
placeholder: |
|
||||||
|
- Reproduced by:
|
||||||
|
- Affected versions / platforms:
|
||||||
|
- Community signal:
|
||||||
|
- Related logs or screenshots:
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: scope
|
||||||
|
attributes:
|
||||||
|
label: Proposed scope
|
||||||
|
description: Describe what is in scope and, if helpful, what is explicitly out of scope.
|
||||||
|
placeholder: |
|
||||||
|
In scope:
|
||||||
|
- ...
|
||||||
|
|
||||||
|
Out of scope:
|
||||||
|
- ...
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: acceptance-criteria
|
||||||
|
attributes:
|
||||||
|
label: Acceptance criteria
|
||||||
|
description: What must be true for this issue to be closed?
|
||||||
|
placeholder: |
|
||||||
|
- [ ] ...
|
||||||
|
- [ ] ...
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: additional-context
|
||||||
|
attributes:
|
||||||
|
label: Additional context
|
||||||
|
description: Links to related PRs, docs, issues in other repositories, roadmap items, or implementation notes.
|
||||||
116
.github/workflows/check-license-dependencies.yml
vendored
116
.github/workflows/check-license-dependencies.yml
vendored
@@ -3,40 +3,108 @@ name: Check License Dependencies
|
|||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [ main ]
|
branches: [ main ]
|
||||||
|
paths:
|
||||||
|
- 'go.mod'
|
||||||
|
- 'go.sum'
|
||||||
|
- '.github/workflows/check-license-dependencies.yml'
|
||||||
pull_request:
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- 'go.mod'
|
||||||
|
- 'go.sum'
|
||||||
|
- '.github/workflows/check-license-dependencies.yml'
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
check-dependencies:
|
check-internal-dependencies:
|
||||||
|
name: Check Internal AGPL Dependencies
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Check for problematic license dependencies
|
||||||
|
run: |
|
||||||
|
echo "Checking for dependencies on management/, signal/, relay/, and proxy/ packages..."
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Find all directories except the problematic ones and system dirs
|
||||||
|
FOUND_ISSUES=0
|
||||||
|
while IFS= read -r dir; do
|
||||||
|
echo "=== Checking $dir ==="
|
||||||
|
# Search for problematic imports, excluding test files
|
||||||
|
RESULTS=$(grep -r "github.com/netbirdio/netbird/\(management\|signal\|relay\|proxy\)" "$dir" --include="*.go" 2>/dev/null | grep -v "_test.go" | grep -v "test_" | grep -v "/test/" | grep -v "tools/idp-migrate/" || true)
|
||||||
|
if [ -n "$RESULTS" ]; then
|
||||||
|
echo "❌ Found problematic dependencies:"
|
||||||
|
echo "$RESULTS"
|
||||||
|
FOUND_ISSUES=1
|
||||||
|
else
|
||||||
|
echo "✓ No problematic dependencies found"
|
||||||
|
fi
|
||||||
|
done < <(find . -maxdepth 1 -type d -not -name "." -not -name "management" -not -name "signal" -not -name "relay" -not -name "proxy" -not -name "combined" -not -name ".git*" | sort)
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
if [ $FOUND_ISSUES -eq 1 ]; then
|
||||||
|
echo "❌ Found dependencies on management/, signal/, relay/, or proxy/ packages"
|
||||||
|
echo "These packages are licensed under AGPLv3 and must not be imported by BSD-licensed code"
|
||||||
|
exit 1
|
||||||
|
else
|
||||||
|
echo ""
|
||||||
|
echo "✅ All internal license dependencies are clean"
|
||||||
|
fi
|
||||||
|
|
||||||
|
check-external-licenses:
|
||||||
|
name: Check External GPL/AGPL Licenses
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Check for problematic license dependencies
|
- name: Set up Go
|
||||||
|
uses: actions/setup-go@v5
|
||||||
|
with:
|
||||||
|
go-version-file: 'go.mod'
|
||||||
|
cache: true
|
||||||
|
|
||||||
|
- name: Install go-licenses
|
||||||
|
run: go install github.com/google/go-licenses@v1.6.0
|
||||||
|
|
||||||
|
- name: Check for GPL/AGPL licensed dependencies
|
||||||
run: |
|
run: |
|
||||||
echo "Checking for dependencies on management/, signal/, and relay/ packages..."
|
echo "Checking for GPL/AGPL/LGPL licensed dependencies..."
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
# Find all directories except the problematic ones and system dirs
|
# Check all Go packages for copyleft licenses, excluding internal netbird packages
|
||||||
FOUND_ISSUES=0
|
COPYLEFT_DEPS=$(go-licenses report ./... 2>/dev/null | grep -E 'GPL|AGPL|LGPL' | grep -v 'github.com/netbirdio/netbird/' || true)
|
||||||
while IFS= read -r dir; do
|
|
||||||
echo "=== Checking $dir ==="
|
if [ -n "$COPYLEFT_DEPS" ]; then
|
||||||
# Search for problematic imports, excluding test files
|
echo "Found copyleft licensed dependencies:"
|
||||||
RESULTS=$(grep -r "github.com/netbirdio/netbird/\(management\|signal\|relay\)" "$dir" --include="*.go" 2>/dev/null | grep -v "_test.go" | grep -v "test_" | grep -v "/test/" || true)
|
echo "$COPYLEFT_DEPS"
|
||||||
if [ -n "$RESULTS" ]; then
|
echo ""
|
||||||
echo "❌ Found problematic dependencies:"
|
|
||||||
echo "$RESULTS"
|
# Filter out dependencies that are only pulled in by internal AGPL packages
|
||||||
FOUND_ISSUES=1
|
INCOMPATIBLE=""
|
||||||
else
|
while IFS=',' read -r package url license; do
|
||||||
echo "✓ No problematic dependencies found"
|
if echo "$license" | grep -qE 'GPL-[0-9]|AGPL-[0-9]|LGPL-[0-9]'; then
|
||||||
|
# Find ALL packages that import this GPL package using go list
|
||||||
|
IMPORTERS=$(go list -json -deps ./... 2>/dev/null | jq -r "select(.Imports[]? == \"$package\") | .ImportPath")
|
||||||
|
|
||||||
|
# Check if any importer is NOT in management/signal/relay
|
||||||
|
BSD_IMPORTER=$(echo "$IMPORTERS" | grep -v "github.com/netbirdio/netbird/\(management\|signal\|relay\|proxy\|combined\|tools/idp-migrate\)" | head -1)
|
||||||
|
|
||||||
|
if [ -n "$BSD_IMPORTER" ]; then
|
||||||
|
echo "❌ $package ($license) is imported by BSD-licensed code: $BSD_IMPORTER"
|
||||||
|
INCOMPATIBLE="${INCOMPATIBLE}${package},${url},${license}\n"
|
||||||
|
else
|
||||||
|
echo "✓ $package ($license) is only used by internal AGPL packages - OK"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
done <<< "$COPYLEFT_DEPS"
|
||||||
|
|
||||||
|
if [ -n "$INCOMPATIBLE" ]; then
|
||||||
|
echo ""
|
||||||
|
echo "❌ INCOMPATIBLE licenses found that are used by BSD-licensed code:"
|
||||||
|
echo -e "$INCOMPATIBLE"
|
||||||
|
exit 1
|
||||||
fi
|
fi
|
||||||
done < <(find . -maxdepth 1 -type d -not -name "." -not -name "management" -not -name "signal" -not -name "relay" -not -name ".git*" | sort)
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
if [ $FOUND_ISSUES -eq 1 ]; then
|
|
||||||
echo "❌ Found dependencies on management/, signal/, or relay/ packages"
|
|
||||||
echo "These packages are licensed under AGPLv3 and must not be imported by BSD-licensed code"
|
|
||||||
exit 1
|
|
||||||
else
|
|
||||||
echo "✅ All license dependencies are clean"
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
echo "✅ All external license dependencies are compatible with BSD-3-Clause"
|
||||||
|
|||||||
9
.github/workflows/golang-test-darwin.yml
vendored
9
.github/workflows/golang-test-darwin.yml
vendored
@@ -15,13 +15,14 @@ jobs:
|
|||||||
name: "Client / Unit"
|
name: "Client / Unit"
|
||||||
runs-on: macos-latest
|
runs-on: macos-latest
|
||||||
steps:
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Install Go
|
- name: Install Go
|
||||||
uses: actions/setup-go@v5
|
uses: actions/setup-go@v5
|
||||||
with:
|
with:
|
||||||
go-version: "1.23.x"
|
go-version-file: "go.mod"
|
||||||
cache: false
|
cache: false
|
||||||
- name: Checkout code
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Cache Go modules
|
- name: Cache Go modules
|
||||||
uses: actions/cache@v4
|
uses: actions/cache@v4
|
||||||
@@ -42,5 +43,5 @@ jobs:
|
|||||||
run: git --no-pager diff --exit-code
|
run: git --no-pager diff --exit-code
|
||||||
|
|
||||||
- name: Test
|
- name: Test
|
||||||
run: NETBIRD_STORE_ENGINE=${{ matrix.store }} CI=true go test -tags=devcert -exec 'sudo --preserve-env=CI,NETBIRD_STORE_ENGINE' -timeout 5m -p 1 $(go list ./... | grep -v /management)
|
run: NETBIRD_STORE_ENGINE=${{ matrix.store }} CI=true go test -tags=devcert -exec 'sudo --preserve-env=CI,NETBIRD_STORE_ENGINE' -timeout 5m -p 1 $(go list ./... | grep -v -e /management -e /signal -e /relay -e /proxy -e /combined)
|
||||||
|
|
||||||
|
|||||||
5
.github/workflows/golang-test-freebsd.yml
vendored
5
.github/workflows/golang-test-freebsd.yml
vendored
@@ -25,7 +25,7 @@ jobs:
|
|||||||
release: "14.2"
|
release: "14.2"
|
||||||
prepare: |
|
prepare: |
|
||||||
pkg install -y curl pkgconf xorg
|
pkg install -y curl pkgconf xorg
|
||||||
GO_TARBALL="go1.23.12.freebsd-amd64.tar.gz"
|
GO_TARBALL="go1.25.3.freebsd-amd64.tar.gz"
|
||||||
GO_URL="https://go.dev/dl/$GO_TARBALL"
|
GO_URL="https://go.dev/dl/$GO_TARBALL"
|
||||||
curl -vLO "$GO_URL"
|
curl -vLO "$GO_URL"
|
||||||
tar -C /usr/local -vxzf "$GO_TARBALL"
|
tar -C /usr/local -vxzf "$GO_TARBALL"
|
||||||
@@ -39,13 +39,12 @@ jobs:
|
|||||||
# check all component except management, since we do not support management server on freebsd
|
# check all component except management, since we do not support management server on freebsd
|
||||||
time go test -timeout 1m -failfast ./base62/...
|
time go test -timeout 1m -failfast ./base62/...
|
||||||
# NOTE: without -p1 `client/internal/dns` will fail because of `listen udp4 :33100: bind: address already in use`
|
# NOTE: without -p1 `client/internal/dns` will fail because of `listen udp4 :33100: bind: address already in use`
|
||||||
time go test -timeout 8m -failfast -p 1 ./client/...
|
time go test -timeout 8m -failfast -v -p 1 ./client/...
|
||||||
time go test -timeout 1m -failfast ./dns/...
|
time go test -timeout 1m -failfast ./dns/...
|
||||||
time go test -timeout 1m -failfast ./encryption/...
|
time go test -timeout 1m -failfast ./encryption/...
|
||||||
time go test -timeout 1m -failfast ./formatter/...
|
time go test -timeout 1m -failfast ./formatter/...
|
||||||
time go test -timeout 1m -failfast ./client/iface/...
|
time go test -timeout 1m -failfast ./client/iface/...
|
||||||
time go test -timeout 1m -failfast ./route/...
|
time go test -timeout 1m -failfast ./route/...
|
||||||
time go test -timeout 1m -failfast ./sharedsock/...
|
time go test -timeout 1m -failfast ./sharedsock/...
|
||||||
time go test -timeout 1m -failfast ./signal/...
|
|
||||||
time go test -timeout 1m -failfast ./util/...
|
time go test -timeout 1m -failfast ./util/...
|
||||||
time go test -timeout 1m -failfast ./version/...
|
time go test -timeout 1m -failfast ./version/...
|
||||||
|
|||||||
171
.github/workflows/golang-test-linux.yml
vendored
171
.github/workflows/golang-test-linux.yml
vendored
@@ -30,7 +30,7 @@ jobs:
|
|||||||
- name: Install Go
|
- name: Install Go
|
||||||
uses: actions/setup-go@v5
|
uses: actions/setup-go@v5
|
||||||
with:
|
with:
|
||||||
go-version: "1.23.x"
|
go-version-file: "go.mod"
|
||||||
cache: false
|
cache: false
|
||||||
|
|
||||||
- name: Get Go environment
|
- name: Get Go environment
|
||||||
@@ -97,6 +97,16 @@ jobs:
|
|||||||
working-directory: relay
|
working-directory: relay
|
||||||
run: CGO_ENABLED=1 GOARCH=386 go build -o relay-386 .
|
run: CGO_ENABLED=1 GOARCH=386 go build -o relay-386 .
|
||||||
|
|
||||||
|
- name: Build combined
|
||||||
|
if: steps.cache.outputs.cache-hit != 'true'
|
||||||
|
working-directory: combined
|
||||||
|
run: CGO_ENABLED=1 go build .
|
||||||
|
|
||||||
|
- name: Build combined 386
|
||||||
|
if: steps.cache.outputs.cache-hit != 'true'
|
||||||
|
working-directory: combined
|
||||||
|
run: CGO_ENABLED=1 GOARCH=386 go build -o combined-386 .
|
||||||
|
|
||||||
test:
|
test:
|
||||||
name: "Client / Unit"
|
name: "Client / Unit"
|
||||||
needs: [build-cache]
|
needs: [build-cache]
|
||||||
@@ -106,15 +116,15 @@ jobs:
|
|||||||
arch: [ '386','amd64' ]
|
arch: [ '386','amd64' ]
|
||||||
runs-on: ubuntu-22.04
|
runs-on: ubuntu-22.04
|
||||||
steps:
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Install Go
|
- name: Install Go
|
||||||
uses: actions/setup-go@v5
|
uses: actions/setup-go@v5
|
||||||
with:
|
with:
|
||||||
go-version: "1.23.x"
|
go-version-file: "go.mod"
|
||||||
cache: false
|
cache: false
|
||||||
|
|
||||||
- name: Checkout code
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Get Go environment
|
- name: Get Go environment
|
||||||
run: |
|
run: |
|
||||||
echo "cache=$(go env GOCACHE)" >> $GITHUB_ENV
|
echo "cache=$(go env GOCACHE)" >> $GITHUB_ENV
|
||||||
@@ -144,22 +154,22 @@ jobs:
|
|||||||
run: git --no-pager diff --exit-code
|
run: git --no-pager diff --exit-code
|
||||||
|
|
||||||
- name: Test
|
- name: Test
|
||||||
run: CGO_ENABLED=1 GOARCH=${{ matrix.arch }} CI=true go test -tags devcert -exec 'sudo' -timeout 10m -p 1 $(go list ./... | grep -v -e /management -e /signal -e /relay)
|
run: CGO_ENABLED=1 GOARCH=${{ matrix.arch }} CI=true go test -tags devcert -exec 'sudo' -timeout 10m -p 1 $(go list ./... | grep -v -e /management -e /signal -e /relay -e /proxy -e /combined)
|
||||||
|
|
||||||
test_client_on_docker:
|
test_client_on_docker:
|
||||||
name: "Client (Docker) / Unit"
|
name: "Client (Docker) / Unit"
|
||||||
needs: [ build-cache ]
|
needs: [ build-cache ]
|
||||||
runs-on: ubuntu-22.04
|
runs-on: ubuntu-22.04
|
||||||
steps:
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Install Go
|
- name: Install Go
|
||||||
uses: actions/setup-go@v5
|
uses: actions/setup-go@v5
|
||||||
with:
|
with:
|
||||||
go-version: "1.23.x"
|
go-version-file: "go.mod"
|
||||||
cache: false
|
cache: false
|
||||||
|
|
||||||
- name: Checkout code
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Get Go environment
|
- name: Get Go environment
|
||||||
id: go-env
|
id: go-env
|
||||||
run: |
|
run: |
|
||||||
@@ -200,11 +210,11 @@ jobs:
|
|||||||
-e GOCACHE=${CONTAINER_GOCACHE} \
|
-e GOCACHE=${CONTAINER_GOCACHE} \
|
||||||
-e GOMODCACHE=${CONTAINER_GOMODCACHE} \
|
-e GOMODCACHE=${CONTAINER_GOMODCACHE} \
|
||||||
-e CONTAINER=${CONTAINER} \
|
-e CONTAINER=${CONTAINER} \
|
||||||
golang:1.23-alpine \
|
golang:1.25-alpine \
|
||||||
sh -c ' \
|
sh -c ' \
|
||||||
apk update; apk add --no-cache \
|
apk update; apk add --no-cache \
|
||||||
ca-certificates iptables ip6tables dbus dbus-dev libpcap-dev build-base; \
|
ca-certificates iptables ip6tables dbus dbus-dev libpcap-dev build-base; \
|
||||||
go test -buildvcs=false -tags devcert -v -timeout 10m -p 1 $(go list -buildvcs=false ./... | grep -v -e /management -e /signal -e /relay -e /client/ui -e /upload-server)
|
go test -buildvcs=false -tags devcert -v -timeout 10m -p 1 $(go list -buildvcs=false ./... | grep -v -e /management -e /signal -e /relay -e /proxy -e /combined -e /client/ui -e /upload-server)
|
||||||
'
|
'
|
||||||
|
|
||||||
test_relay:
|
test_relay:
|
||||||
@@ -220,15 +230,15 @@ jobs:
|
|||||||
raceFlag: "-race"
|
raceFlag: "-race"
|
||||||
runs-on: ubuntu-22.04
|
runs-on: ubuntu-22.04
|
||||||
steps:
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Install Go
|
- name: Install Go
|
||||||
uses: actions/setup-go@v5
|
uses: actions/setup-go@v5
|
||||||
with:
|
with:
|
||||||
go-version: "1.23.x"
|
go-version-file: "go.mod"
|
||||||
cache: false
|
cache: false
|
||||||
|
|
||||||
- name: Checkout code
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
if: steps.cache.outputs.cache-hit != 'true'
|
if: steps.cache.outputs.cache-hit != 'true'
|
||||||
run: sudo apt update && sudo apt install -y gcc-multilib g++-multilib libc6-dev-i386
|
run: sudo apt update && sudo apt install -y gcc-multilib g++-multilib libc6-dev-i386
|
||||||
@@ -259,7 +269,54 @@ jobs:
|
|||||||
CGO_ENABLED=1 GOARCH=${{ matrix.arch }} \
|
CGO_ENABLED=1 GOARCH=${{ matrix.arch }} \
|
||||||
go test ${{ matrix.raceFlag }} \
|
go test ${{ matrix.raceFlag }} \
|
||||||
-exec 'sudo' \
|
-exec 'sudo' \
|
||||||
-timeout 10m ./relay/... ./shared/relay/...
|
-timeout 10m -p 1 ./relay/... ./shared/relay/...
|
||||||
|
|
||||||
|
test_proxy:
|
||||||
|
name: "Proxy / Unit"
|
||||||
|
needs: [build-cache]
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
arch: [ '386','amd64' ]
|
||||||
|
runs-on: ubuntu-22.04
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install Go
|
||||||
|
uses: actions/setup-go@v5
|
||||||
|
with:
|
||||||
|
go-version-file: "go.mod"
|
||||||
|
cache: false
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: sudo apt update && sudo apt install -y gcc-multilib g++-multilib libc6-dev-i386
|
||||||
|
|
||||||
|
- name: Get Go environment
|
||||||
|
run: |
|
||||||
|
echo "cache=$(go env GOCACHE)" >> $GITHUB_ENV
|
||||||
|
echo "modcache=$(go env GOMODCACHE)" >> $GITHUB_ENV
|
||||||
|
|
||||||
|
- name: Cache Go modules
|
||||||
|
uses: actions/cache/restore@v4
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
${{ env.cache }}
|
||||||
|
${{ env.modcache }}
|
||||||
|
key: ${{ runner.os }}-gotest-cache-${{ hashFiles('**/go.sum') }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-gotest-cache-
|
||||||
|
|
||||||
|
- name: Install modules
|
||||||
|
run: go mod tidy
|
||||||
|
|
||||||
|
- name: check git status
|
||||||
|
run: git --no-pager diff --exit-code
|
||||||
|
|
||||||
|
- name: Test
|
||||||
|
run: |
|
||||||
|
CGO_ENABLED=1 GOARCH=${{ matrix.arch }} \
|
||||||
|
go test -timeout 10m -p 1 ./proxy/...
|
||||||
|
|
||||||
test_signal:
|
test_signal:
|
||||||
name: "Signal / Unit"
|
name: "Signal / Unit"
|
||||||
@@ -270,15 +327,15 @@ jobs:
|
|||||||
arch: [ '386','amd64' ]
|
arch: [ '386','amd64' ]
|
||||||
runs-on: ubuntu-22.04
|
runs-on: ubuntu-22.04
|
||||||
steps:
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Install Go
|
- name: Install Go
|
||||||
uses: actions/setup-go@v5
|
uses: actions/setup-go@v5
|
||||||
with:
|
with:
|
||||||
go-version: "1.23.x"
|
go-version-file: "go.mod"
|
||||||
cache: false
|
cache: false
|
||||||
|
|
||||||
- name: Checkout code
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
if: steps.cache.outputs.cache-hit != 'true'
|
if: steps.cache.outputs.cache-hit != 'true'
|
||||||
run: sudo apt update && sudo apt install -y gcc-multilib g++-multilib libc6-dev-i386
|
run: sudo apt update && sudo apt install -y gcc-multilib g++-multilib libc6-dev-i386
|
||||||
@@ -321,15 +378,15 @@ jobs:
|
|||||||
store: [ 'sqlite', 'postgres', 'mysql' ]
|
store: [ 'sqlite', 'postgres', 'mysql' ]
|
||||||
runs-on: ubuntu-22.04
|
runs-on: ubuntu-22.04
|
||||||
steps:
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Install Go
|
- name: Install Go
|
||||||
uses: actions/setup-go@v5
|
uses: actions/setup-go@v5
|
||||||
with:
|
with:
|
||||||
go-version: "1.23.x"
|
go-version-file: "go.mod"
|
||||||
cache: false
|
cache: false
|
||||||
|
|
||||||
- name: Checkout code
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Get Go environment
|
- name: Get Go environment
|
||||||
run: |
|
run: |
|
||||||
echo "cache=$(go env GOCACHE)" >> $GITHUB_ENV
|
echo "cache=$(go env GOCACHE)" >> $GITHUB_ENV
|
||||||
@@ -352,12 +409,19 @@ jobs:
|
|||||||
run: git --no-pager diff --exit-code
|
run: git --no-pager diff --exit-code
|
||||||
|
|
||||||
- name: Login to Docker hub
|
- name: Login to Docker hub
|
||||||
if: matrix.store == 'mysql' && (github.repository == github.head.repo.full_name || !github.head_ref)
|
if: github.event.pull_request && github.event.pull_request.head.repo && github.event.pull_request.head.repo.full_name == '' || github.repository == github.event.pull_request.head.repo.full_name || !github.head_ref
|
||||||
uses: docker/login-action@v1
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKER_USER }}
|
username: ${{ secrets.DOCKER_USER }}
|
||||||
password: ${{ secrets.DOCKER_TOKEN }}
|
password: ${{ secrets.DOCKER_TOKEN }}
|
||||||
|
|
||||||
|
- name: docker login for root user
|
||||||
|
if: github.event.pull_request && github.event.pull_request.head.repo && github.event.pull_request.head.repo.full_name == '' || github.repository == github.event.pull_request.head.repo.full_name || !github.head_ref
|
||||||
|
env:
|
||||||
|
DOCKER_USER: ${{ secrets.DOCKER_USER }}
|
||||||
|
DOCKER_TOKEN: ${{ secrets.DOCKER_TOKEN }}
|
||||||
|
run: echo "$DOCKER_TOKEN" | sudo docker login --username "$DOCKER_USER" --password-stdin
|
||||||
|
|
||||||
- name: download mysql image
|
- name: download mysql image
|
||||||
if: matrix.store == 'mysql'
|
if: matrix.store == 'mysql'
|
||||||
run: docker pull mlsmaycon/warmed-mysql:8
|
run: docker pull mlsmaycon/warmed-mysql:8
|
||||||
@@ -408,15 +472,16 @@ jobs:
|
|||||||
-v $PWD/prometheus.yml:/etc/prometheus/prometheus.yml \
|
-v $PWD/prometheus.yml:/etc/prometheus/prometheus.yml \
|
||||||
-p 9090:9090 \
|
-p 9090:9090 \
|
||||||
prom/prometheus
|
prom/prometheus
|
||||||
- name: Install Go
|
|
||||||
uses: actions/setup-go@v5
|
|
||||||
with:
|
|
||||||
go-version: "1.23.x"
|
|
||||||
cache: false
|
|
||||||
|
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install Go
|
||||||
|
uses: actions/setup-go@v5
|
||||||
|
with:
|
||||||
|
go-version-file: "go.mod"
|
||||||
|
cache: false
|
||||||
|
|
||||||
- name: Get Go environment
|
- name: Get Go environment
|
||||||
run: |
|
run: |
|
||||||
echo "cache=$(go env GOCACHE)" >> $GITHUB_ENV
|
echo "cache=$(go env GOCACHE)" >> $GITHUB_ENV
|
||||||
@@ -439,15 +504,18 @@ jobs:
|
|||||||
run: git --no-pager diff --exit-code
|
run: git --no-pager diff --exit-code
|
||||||
|
|
||||||
- name: Login to Docker hub
|
- name: Login to Docker hub
|
||||||
if: matrix.store == 'mysql' && (github.repository == github.head.repo.full_name || !github.head_ref)
|
if: github.event.pull_request && github.event.pull_request.head.repo && github.event.pull_request.head.repo.full_name == '' || github.repository == github.event.pull_request.head.repo.full_name || !github.head_ref
|
||||||
uses: docker/login-action@v1
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKER_USER }}
|
username: ${{ secrets.DOCKER_USER }}
|
||||||
password: ${{ secrets.DOCKER_TOKEN }}
|
password: ${{ secrets.DOCKER_TOKEN }}
|
||||||
|
|
||||||
- name: download mysql image
|
- name: docker login for root user
|
||||||
if: matrix.store == 'mysql'
|
if: github.event.pull_request && github.event.pull_request.head.repo && github.event.pull_request.head.repo.full_name == '' || github.repository == github.event.pull_request.head.repo.full_name || !github.head_ref
|
||||||
run: docker pull mlsmaycon/warmed-mysql:8
|
env:
|
||||||
|
DOCKER_USER: ${{ secrets.DOCKER_USER }}
|
||||||
|
DOCKER_TOKEN: ${{ secrets.DOCKER_TOKEN }}
|
||||||
|
run: echo "$DOCKER_TOKEN" | sudo docker login --username "$DOCKER_USER" --password-stdin
|
||||||
|
|
||||||
- name: Test
|
- name: Test
|
||||||
run: |
|
run: |
|
||||||
@@ -497,15 +565,15 @@ jobs:
|
|||||||
-p 9090:9090 \
|
-p 9090:9090 \
|
||||||
prom/prometheus
|
prom/prometheus
|
||||||
|
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Install Go
|
- name: Install Go
|
||||||
uses: actions/setup-go@v5
|
uses: actions/setup-go@v5
|
||||||
with:
|
with:
|
||||||
go-version: "1.23.x"
|
go-version-file: "go.mod"
|
||||||
cache: false
|
cache: false
|
||||||
|
|
||||||
- name: Checkout code
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Get Go environment
|
- name: Get Go environment
|
||||||
run: |
|
run: |
|
||||||
echo "cache=$(go env GOCACHE)" >> $GITHUB_ENV
|
echo "cache=$(go env GOCACHE)" >> $GITHUB_ENV
|
||||||
@@ -528,15 +596,18 @@ jobs:
|
|||||||
run: git --no-pager diff --exit-code
|
run: git --no-pager diff --exit-code
|
||||||
|
|
||||||
- name: Login to Docker hub
|
- name: Login to Docker hub
|
||||||
if: matrix.store == 'mysql' && (github.repository == github.head.repo.full_name || !github.head_ref)
|
if: github.event.pull_request && github.event.pull_request.head.repo && github.event.pull_request.head.repo.full_name == '' || github.repository == github.event.pull_request.head.repo.full_name || !github.head_ref
|
||||||
uses: docker/login-action@v1
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKER_USER }}
|
username: ${{ secrets.DOCKER_USER }}
|
||||||
password: ${{ secrets.DOCKER_TOKEN }}
|
password: ${{ secrets.DOCKER_TOKEN }}
|
||||||
|
|
||||||
- name: download mysql image
|
- name: docker login for root user
|
||||||
if: matrix.store == 'mysql'
|
if: github.event.pull_request && github.event.pull_request.head.repo && github.event.pull_request.head.repo.full_name == '' || github.repository == github.event.pull_request.head.repo.full_name || !github.head_ref
|
||||||
run: docker pull mlsmaycon/warmed-mysql:8
|
env:
|
||||||
|
DOCKER_USER: ${{ secrets.DOCKER_USER }}
|
||||||
|
DOCKER_TOKEN: ${{ secrets.DOCKER_TOKEN }}
|
||||||
|
run: echo "$DOCKER_TOKEN" | sudo docker login --username "$DOCKER_USER" --password-stdin
|
||||||
|
|
||||||
- name: Test
|
- name: Test
|
||||||
run: |
|
run: |
|
||||||
@@ -561,15 +632,15 @@ jobs:
|
|||||||
store: [ 'sqlite', 'postgres']
|
store: [ 'sqlite', 'postgres']
|
||||||
runs-on: ubuntu-22.04
|
runs-on: ubuntu-22.04
|
||||||
steps:
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Install Go
|
- name: Install Go
|
||||||
uses: actions/setup-go@v5
|
uses: actions/setup-go@v5
|
||||||
with:
|
with:
|
||||||
go-version: "1.23.x"
|
go-version-file: "go.mod"
|
||||||
cache: false
|
cache: false
|
||||||
|
|
||||||
- name: Checkout code
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Get Go environment
|
- name: Get Go environment
|
||||||
run: |
|
run: |
|
||||||
echo "cache=$(go env GOCACHE)" >> $GITHUB_ENV
|
echo "cache=$(go env GOCACHE)" >> $GITHUB_ENV
|
||||||
|
|||||||
11
.github/workflows/golang-test-windows.yml
vendored
11
.github/workflows/golang-test-windows.yml
vendored
@@ -24,7 +24,7 @@ jobs:
|
|||||||
uses: actions/setup-go@v5
|
uses: actions/setup-go@v5
|
||||||
id: go
|
id: go
|
||||||
with:
|
with:
|
||||||
go-version: "1.23.x"
|
go-version-file: "go.mod"
|
||||||
cache: false
|
cache: false
|
||||||
|
|
||||||
- name: Get Go environment
|
- name: Get Go environment
|
||||||
@@ -63,10 +63,15 @@ jobs:
|
|||||||
- run: PsExec64 -s -w ${{ github.workspace }} C:\hostedtoolcache\windows\go\${{ steps.go.outputs.go-version }}\x64\bin\go.exe env -w GOMODCACHE=${{ env.cache }}
|
- run: PsExec64 -s -w ${{ github.workspace }} C:\hostedtoolcache\windows\go\${{ steps.go.outputs.go-version }}\x64\bin\go.exe env -w GOMODCACHE=${{ env.cache }}
|
||||||
- run: PsExec64 -s -w ${{ github.workspace }} C:\hostedtoolcache\windows\go\${{ steps.go.outputs.go-version }}\x64\bin\go.exe env -w GOCACHE=${{ env.modcache }}
|
- run: PsExec64 -s -w ${{ github.workspace }} C:\hostedtoolcache\windows\go\${{ steps.go.outputs.go-version }}\x64\bin\go.exe env -w GOCACHE=${{ env.modcache }}
|
||||||
- run: PsExec64 -s -w ${{ github.workspace }} C:\hostedtoolcache\windows\go\${{ steps.go.outputs.go-version }}\x64\bin\go.exe mod tidy
|
- run: PsExec64 -s -w ${{ github.workspace }} C:\hostedtoolcache\windows\go\${{ steps.go.outputs.go-version }}\x64\bin\go.exe mod tidy
|
||||||
- run: echo "files=$(go list ./... | ForEach-Object { $_ } | Where-Object { $_ -notmatch '/management' } | Where-Object { $_ -notmatch '/relay' } | Where-Object { $_ -notmatch '/signal' })" >> $env:GITHUB_ENV
|
- name: Generate test script
|
||||||
|
run: |
|
||||||
|
$packages = go list ./... | Where-Object { $_ -notmatch '/management' } | Where-Object { $_ -notmatch '/relay' } | Where-Object { $_ -notmatch '/signal' } | Where-Object { $_ -notmatch '/proxy' } | Where-Object { $_ -notmatch '/combined' }
|
||||||
|
$goExe = "C:\hostedtoolcache\windows\go\${{ steps.go.outputs.go-version }}\x64\bin\go.exe"
|
||||||
|
$cmd = "$goExe test -tags=devcert -timeout 10m -p 1 $($packages -join ' ') > test-out.txt 2>&1"
|
||||||
|
Set-Content -Path "${{ github.workspace }}\run-tests.cmd" -Value $cmd
|
||||||
|
|
||||||
- name: test
|
- name: test
|
||||||
run: PsExec64 -s -w ${{ github.workspace }} cmd.exe /c "C:\hostedtoolcache\windows\go\${{ steps.go.outputs.go-version }}\x64\bin\go.exe test -tags=devcert -timeout 10m -p 1 ${{ env.files }} > test-out.txt 2>&1"
|
run: PsExec64 -s -w ${{ github.workspace }} cmd.exe /c "${{ github.workspace }}\run-tests.cmd"
|
||||||
- name: test output
|
- name: test output
|
||||||
if: ${{ always() }}
|
if: ${{ always() }}
|
||||||
run: Get-Content test-out.txt
|
run: Get-Content test-out.txt
|
||||||
|
|||||||
13
.github/workflows/golangci-lint.yml
vendored
13
.github/workflows/golangci-lint.yml
vendored
@@ -19,8 +19,8 @@ jobs:
|
|||||||
- name: codespell
|
- name: codespell
|
||||||
uses: codespell-project/actions-codespell@v2
|
uses: codespell-project/actions-codespell@v2
|
||||||
with:
|
with:
|
||||||
ignore_words_list: erro,clienta,hastable,iif,groupd,testin,groupe,cros
|
ignore_words_list: erro,clienta,hastable,iif,groupd,testin,groupe,cros,ans,deriver,te,userA,ede,additionals
|
||||||
skip: go.mod,go.sum
|
skip: go.mod,go.sum,**/proxy/web/**
|
||||||
golangci:
|
golangci:
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
@@ -46,13 +46,16 @@ jobs:
|
|||||||
- name: Install Go
|
- name: Install Go
|
||||||
uses: actions/setup-go@v5
|
uses: actions/setup-go@v5
|
||||||
with:
|
with:
|
||||||
go-version: "1.23.x"
|
go-version-file: "go.mod"
|
||||||
cache: false
|
cache: false
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
if: matrix.os == 'ubuntu-latest'
|
if: matrix.os == 'ubuntu-latest'
|
||||||
run: sudo apt update && sudo apt install -y -q libgtk-3-dev libayatana-appindicator3-dev libgl1-mesa-dev xorg-dev libpcap-dev
|
run: sudo apt update && sudo apt install -y -q libgtk-3-dev libayatana-appindicator3-dev libgl1-mesa-dev xorg-dev libpcap-dev
|
||||||
- name: golangci-lint
|
- name: golangci-lint
|
||||||
uses: golangci/golangci-lint-action@v4
|
uses: golangci/golangci-lint-action@4afd733a84b1f43292c63897423277bb7f4313a9 # v8.0.0
|
||||||
with:
|
with:
|
||||||
version: latest
|
version: latest
|
||||||
args: --timeout=12m --out-format colored-line-number
|
skip-cache: true
|
||||||
|
skip-save-cache: true
|
||||||
|
cache-invalidation-interval: 0
|
||||||
|
args: --timeout=12m
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ jobs:
|
|||||||
- name: Install Go
|
- name: Install Go
|
||||||
uses: actions/setup-go@v5
|
uses: actions/setup-go@v5
|
||||||
with:
|
with:
|
||||||
go-version: "1.23.x"
|
go-version-file: "go.mod"
|
||||||
- name: Setup Android SDK
|
- name: Setup Android SDK
|
||||||
uses: android-actions/setup-android@v3
|
uses: android-actions/setup-android@v3
|
||||||
with:
|
with:
|
||||||
@@ -39,7 +39,7 @@ jobs:
|
|||||||
- name: Setup NDK
|
- name: Setup NDK
|
||||||
run: /usr/local/lib/android/sdk/cmdline-tools/7.0/bin/sdkmanager --install "ndk;23.1.7779620"
|
run: /usr/local/lib/android/sdk/cmdline-tools/7.0/bin/sdkmanager --install "ndk;23.1.7779620"
|
||||||
- name: install gomobile
|
- name: install gomobile
|
||||||
run: go install golang.org/x/mobile/cmd/gomobile@v0.0.0-20240404231514-09dbf07665ed
|
run: go install golang.org/x/mobile/cmd/gomobile@v0.0.0-20251113184115-a159579294ab
|
||||||
- name: gomobile init
|
- name: gomobile init
|
||||||
run: gomobile init
|
run: gomobile init
|
||||||
- name: build android netbird lib
|
- name: build android netbird lib
|
||||||
@@ -56,9 +56,9 @@ jobs:
|
|||||||
- name: Install Go
|
- name: Install Go
|
||||||
uses: actions/setup-go@v5
|
uses: actions/setup-go@v5
|
||||||
with:
|
with:
|
||||||
go-version: "1.23.x"
|
go-version-file: "go.mod"
|
||||||
- name: install gomobile
|
- name: install gomobile
|
||||||
run: go install golang.org/x/mobile/cmd/gomobile@v0.0.0-20240404231514-09dbf07665ed
|
run: go install golang.org/x/mobile/cmd/gomobile@v0.0.0-20251113184115-a159579294ab
|
||||||
- name: gomobile init
|
- name: gomobile init
|
||||||
run: gomobile init
|
run: gomobile init
|
||||||
- name: build iOS netbird lib
|
- name: build iOS netbird lib
|
||||||
|
|||||||
51
.github/workflows/pr-title-check.yml
vendored
Normal file
51
.github/workflows/pr-title-check.yml
vendored
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
name: PR Title Check
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
types: [opened, edited, synchronize, reopened]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
check-title:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Validate PR title prefix
|
||||||
|
uses: actions/github-script@v7
|
||||||
|
with:
|
||||||
|
script: |
|
||||||
|
const title = context.payload.pull_request.title;
|
||||||
|
const allowedTags = [
|
||||||
|
'management',
|
||||||
|
'client',
|
||||||
|
'signal',
|
||||||
|
'proxy',
|
||||||
|
'relay',
|
||||||
|
'misc',
|
||||||
|
'infrastructure',
|
||||||
|
'self-hosted',
|
||||||
|
'doc',
|
||||||
|
];
|
||||||
|
|
||||||
|
const pattern = /^\[([^\]]+)\]\s+.+/;
|
||||||
|
const match = title.match(pattern);
|
||||||
|
|
||||||
|
if (!match) {
|
||||||
|
core.setFailed(
|
||||||
|
`PR title must start with a tag in brackets.\n` +
|
||||||
|
`Example: [client] fix something\n` +
|
||||||
|
`Allowed tags: ${allowedTags.join(', ')}`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tags = match[1].split(',').map(t => t.trim().toLowerCase());
|
||||||
|
|
||||||
|
const invalid = tags.filter(t => !allowedTags.includes(t));
|
||||||
|
if (invalid.length > 0) {
|
||||||
|
core.setFailed(
|
||||||
|
`Invalid tag(s): ${invalid.join(', ')}\n` +
|
||||||
|
`Allowed tags: ${allowedTags.join(', ')}`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Valid PR title tags: [${tags.join(', ')}]`);
|
||||||
62
.github/workflows/proto-version-check.yml
vendored
Normal file
62
.github/workflows/proto-version-check.yml
vendored
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
name: Proto Version Check
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- "**/*.pb.go"
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
check-proto-versions:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Check for proto tool version changes
|
||||||
|
uses: actions/github-script@v7
|
||||||
|
with:
|
||||||
|
script: |
|
||||||
|
const files = await github.paginate(github.rest.pulls.listFiles, {
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
pull_number: context.issue.number,
|
||||||
|
per_page: 100,
|
||||||
|
});
|
||||||
|
|
||||||
|
const pbFiles = files.filter(f => f.filename.endsWith('.pb.go'));
|
||||||
|
const missingPatch = pbFiles.filter(f => !f.patch).map(f => f.filename);
|
||||||
|
if (missingPatch.length > 0) {
|
||||||
|
core.setFailed(
|
||||||
|
`Cannot inspect patch data for:\n` +
|
||||||
|
missingPatch.map(f => `- ${f}`).join('\n') +
|
||||||
|
`\nThis can happen with very large PRs. Verify proto versions manually.`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const versionPattern = /^[+-]\s*\/\/\s+protoc(?:-gen-go)?\s+v[\d.]+/;
|
||||||
|
const violations = [];
|
||||||
|
|
||||||
|
for (const file of pbFiles) {
|
||||||
|
const changed = file.patch
|
||||||
|
.split('\n')
|
||||||
|
.filter(line => versionPattern.test(line));
|
||||||
|
if (changed.length > 0) {
|
||||||
|
violations.push({
|
||||||
|
file: file.filename,
|
||||||
|
lines: changed,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (violations.length > 0) {
|
||||||
|
const details = violations.map(v =>
|
||||||
|
`${v.file}:\n${v.lines.map(l => ' ' + l).join('\n')}`
|
||||||
|
).join('\n\n');
|
||||||
|
|
||||||
|
core.setFailed(
|
||||||
|
`Proto version strings changed in generated files.\n` +
|
||||||
|
`This usually means the wrong protoc or protoc-gen-go version was used.\n` +
|
||||||
|
`Regenerate with the matching tool versions.\n\n` +
|
||||||
|
details
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('No proto version string changes detected');
|
||||||
483
.github/workflows/release.yml
vendored
483
.github/workflows/release.yml
vendored
@@ -9,8 +9,8 @@ on:
|
|||||||
pull_request:
|
pull_request:
|
||||||
|
|
||||||
env:
|
env:
|
||||||
SIGN_PIPE_VER: "v0.0.23"
|
SIGN_PIPE_VER: "v0.1.4"
|
||||||
GORELEASER_VER: "v2.3.2"
|
GORELEASER_VER: "v2.14.3"
|
||||||
PRODUCT_NAME: "NetBird"
|
PRODUCT_NAME: "NetBird"
|
||||||
COPYRIGHT: "NetBird GmbH"
|
COPYRIGHT: "NetBird GmbH"
|
||||||
|
|
||||||
@@ -19,8 +19,108 @@ concurrency:
|
|||||||
cancel-in-progress: true
|
cancel-in-progress: true
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
release:
|
release_freebsd_port:
|
||||||
|
name: "FreeBSD Port / Build & Test"
|
||||||
runs-on: ubuntu-22.04
|
runs-on: ubuntu-22.04
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Generate FreeBSD port diff
|
||||||
|
run: bash release_files/freebsd-port-diff.sh
|
||||||
|
|
||||||
|
- name: Generate FreeBSD port issue body
|
||||||
|
run: bash release_files/freebsd-port-issue-body.sh
|
||||||
|
|
||||||
|
- name: Check if diff was generated
|
||||||
|
id: check_diff
|
||||||
|
run: |
|
||||||
|
if ls netbird-*.diff 1> /dev/null 2>&1; then
|
||||||
|
echo "diff_exists=true" >> $GITHUB_OUTPUT
|
||||||
|
else
|
||||||
|
echo "diff_exists=false" >> $GITHUB_OUTPUT
|
||||||
|
echo "No diff file generated (port may already be up to date)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Extract version
|
||||||
|
if: steps.check_diff.outputs.diff_exists == 'true'
|
||||||
|
id: version
|
||||||
|
run: |
|
||||||
|
VERSION=$(ls netbird-*.diff | sed 's/netbird-\(.*\)\.diff/\1/')
|
||||||
|
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||||
|
echo "Generated files for version: $VERSION"
|
||||||
|
cat netbird-*.diff
|
||||||
|
|
||||||
|
- name: Test FreeBSD port
|
||||||
|
if: steps.check_diff.outputs.diff_exists == 'true'
|
||||||
|
uses: vmactions/freebsd-vm@v1
|
||||||
|
with:
|
||||||
|
usesh: true
|
||||||
|
copyback: false
|
||||||
|
release: "15.0"
|
||||||
|
prepare: |
|
||||||
|
# Install required packages
|
||||||
|
pkg install -y git curl portlint go
|
||||||
|
|
||||||
|
# Install Go for building
|
||||||
|
GO_TARBALL="go1.25.5.freebsd-amd64.tar.gz"
|
||||||
|
GO_URL="https://go.dev/dl/$GO_TARBALL"
|
||||||
|
curl -LO "$GO_URL"
|
||||||
|
tar -C /usr/local -xzf "$GO_TARBALL"
|
||||||
|
|
||||||
|
# Clone ports tree (shallow, only what we need)
|
||||||
|
git clone --depth 1 --filter=blob:none https://git.FreeBSD.org/ports.git /usr/ports
|
||||||
|
cd /usr/ports
|
||||||
|
|
||||||
|
run: |
|
||||||
|
set -e -x
|
||||||
|
export PATH=$PATH:/usr/local/go/bin
|
||||||
|
|
||||||
|
# Find the diff file
|
||||||
|
echo "Finding diff file..."
|
||||||
|
DIFF_FILE=$(find $PWD -name "netbird-*.diff" -type f 2>/dev/null | head -1)
|
||||||
|
echo "Found: $DIFF_FILE"
|
||||||
|
|
||||||
|
if [[ -z "$DIFF_FILE" ]]; then
|
||||||
|
echo "ERROR: Could not find diff file"
|
||||||
|
find ~ -name "*.diff" -type f 2>/dev/null || true
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Apply the generated diff from /usr/ports (diff has a/security/netbird/... paths)
|
||||||
|
cd /usr/ports
|
||||||
|
patch -p1 -V none < "$DIFF_FILE"
|
||||||
|
|
||||||
|
# Show patched Makefile
|
||||||
|
version=$(cat security/netbird/Makefile | grep -E '^DISTVERSION=' | awk '{print $NF}')
|
||||||
|
|
||||||
|
cd /usr/ports/security/netbird
|
||||||
|
export BATCH=yes
|
||||||
|
make package
|
||||||
|
pkg add ./work/pkg/netbird-*.pkg
|
||||||
|
|
||||||
|
netbird version | grep "$version"
|
||||||
|
|
||||||
|
echo "FreeBSD port test completed successfully!"
|
||||||
|
|
||||||
|
- name: Upload FreeBSD port files
|
||||||
|
if: steps.check_diff.outputs.diff_exists == 'true'
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: freebsd-port-files
|
||||||
|
path: |
|
||||||
|
./netbird-*-issue.txt
|
||||||
|
./netbird-*.diff
|
||||||
|
retention-days: 30
|
||||||
|
|
||||||
|
release:
|
||||||
|
runs-on: ubuntu-24.04-8-core
|
||||||
|
outputs:
|
||||||
|
release_artifact_url: ${{ steps.upload_release.outputs.artifact-url }}
|
||||||
|
linux_packages_artifact_url: ${{ steps.upload_linux_packages.outputs.artifact-url }}
|
||||||
|
windows_packages_artifact_url: ${{ steps.upload_windows_packages.outputs.artifact-url }}
|
||||||
|
macos_packages_artifact_url: ${{ steps.upload_macos_packages.outputs.artifact-url }}
|
||||||
|
ghcr_images: ${{ steps.tag_and_push_images.outputs.images_markdown }}
|
||||||
env:
|
env:
|
||||||
flags: ""
|
flags: ""
|
||||||
steps:
|
steps:
|
||||||
@@ -40,7 +140,7 @@ jobs:
|
|||||||
- name: Set up Go
|
- name: Set up Go
|
||||||
uses: actions/setup-go@v5
|
uses: actions/setup-go@v5
|
||||||
with:
|
with:
|
||||||
go-version: "1.23"
|
go-version-file: "go.mod"
|
||||||
cache: false
|
cache: false
|
||||||
- name: Cache Go modules
|
- name: Cache Go modules
|
||||||
uses: actions/cache@v4
|
uses: actions/cache@v4
|
||||||
@@ -66,7 +166,7 @@ jobs:
|
|||||||
username: ${{ secrets.DOCKER_USER }}
|
username: ${{ secrets.DOCKER_USER }}
|
||||||
password: ${{ secrets.DOCKER_TOKEN }}
|
password: ${{ secrets.DOCKER_TOKEN }}
|
||||||
- name: Log in to the GitHub container registry
|
- name: Log in to the GitHub container registry
|
||||||
if: github.event_name != 'pull_request'
|
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
@@ -75,6 +175,14 @@ jobs:
|
|||||||
- name: Install OS build dependencies
|
- name: Install OS build dependencies
|
||||||
run: sudo apt update && sudo apt install -y -q gcc-arm-linux-gnueabihf gcc-aarch64-linux-gnu
|
run: sudo apt update && sudo apt install -y -q gcc-arm-linux-gnueabihf gcc-aarch64-linux-gnu
|
||||||
|
|
||||||
|
- name: Decode GPG signing key
|
||||||
|
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
|
||||||
|
env:
|
||||||
|
GPG_RPM_PRIVATE_KEY: ${{ secrets.GPG_RPM_PRIVATE_KEY }}
|
||||||
|
run: |
|
||||||
|
echo "$GPG_RPM_PRIVATE_KEY" | base64 -d > /tmp/gpg-rpm-signing-key.asc
|
||||||
|
echo "GPG_RPM_KEY_FILE=/tmp/gpg-rpm-signing-key.asc" >> $GITHUB_ENV
|
||||||
|
|
||||||
- name: Install goversioninfo
|
- name: Install goversioninfo
|
||||||
run: go install github.com/josephspurrier/goversioninfo/cmd/goversioninfo@233067e
|
run: go install github.com/josephspurrier/goversioninfo/cmd/goversioninfo@233067e
|
||||||
- name: Generate windows syso amd64
|
- name: Generate windows syso amd64
|
||||||
@@ -82,6 +190,7 @@ jobs:
|
|||||||
- name: Generate windows syso arm64
|
- name: Generate windows syso arm64
|
||||||
run: goversioninfo -arm -64 -icon client/ui/assets/netbird.ico -manifest client/manifest.xml -product-name ${{ env.PRODUCT_NAME }} -copyright "${{ env.COPYRIGHT }}" -ver-major ${{ steps.semver_parser.outputs.major }} -ver-minor ${{ steps.semver_parser.outputs.minor }} -ver-patch ${{ steps.semver_parser.outputs.patch }} -ver-build 0 -file-version ${{ steps.semver_parser.outputs.fullversion }}.0 -product-version ${{ steps.semver_parser.outputs.fullversion }}.0 -o client/resources_windows_arm64.syso
|
run: goversioninfo -arm -64 -icon client/ui/assets/netbird.ico -manifest client/manifest.xml -product-name ${{ env.PRODUCT_NAME }} -copyright "${{ env.COPYRIGHT }}" -ver-major ${{ steps.semver_parser.outputs.major }} -ver-minor ${{ steps.semver_parser.outputs.minor }} -ver-patch ${{ steps.semver_parser.outputs.patch }} -ver-build 0 -file-version ${{ steps.semver_parser.outputs.fullversion }}.0 -product-version ${{ steps.semver_parser.outputs.fullversion }}.0 -o client/resources_windows_arm64.syso
|
||||||
- name: Run GoReleaser
|
- name: Run GoReleaser
|
||||||
|
id: goreleaser
|
||||||
uses: goreleaser/goreleaser-action@v4
|
uses: goreleaser/goreleaser-action@v4
|
||||||
with:
|
with:
|
||||||
version: ${{ env.GORELEASER_VER }}
|
version: ${{ env.GORELEASER_VER }}
|
||||||
@@ -91,25 +200,109 @@ jobs:
|
|||||||
HOMEBREW_TAP_GITHUB_TOKEN: ${{ secrets.HOMEBREW_TAP_GITHUB_TOKEN }}
|
HOMEBREW_TAP_GITHUB_TOKEN: ${{ secrets.HOMEBREW_TAP_GITHUB_TOKEN }}
|
||||||
UPLOAD_DEBIAN_SECRET: ${{ secrets.PKG_UPLOAD_SECRET }}
|
UPLOAD_DEBIAN_SECRET: ${{ secrets.PKG_UPLOAD_SECRET }}
|
||||||
UPLOAD_YUM_SECRET: ${{ secrets.PKG_UPLOAD_SECRET }}
|
UPLOAD_YUM_SECRET: ${{ secrets.PKG_UPLOAD_SECRET }}
|
||||||
|
GPG_RPM_KEY_FILE: ${{ env.GPG_RPM_KEY_FILE }}
|
||||||
|
NFPM_NETBIRD_RPM_PASSPHRASE: ${{ secrets.GPG_RPM_PASSPHRASE }}
|
||||||
|
- name: Verify RPM signatures
|
||||||
|
run: |
|
||||||
|
docker run --rm -v $(pwd)/dist:/dist fedora:41 bash -c '
|
||||||
|
dnf install -y -q rpm-sign curl >/dev/null 2>&1
|
||||||
|
curl -sSL https://pkgs.netbird.io/yum/repodata/repomd.xml.key -o /tmp/rpm-pub.key
|
||||||
|
rpm --import /tmp/rpm-pub.key
|
||||||
|
echo "=== Verifying RPM signatures ==="
|
||||||
|
for rpm_file in /dist/*amd64*.rpm; do
|
||||||
|
[ -f "$rpm_file" ] || continue
|
||||||
|
echo "--- $(basename $rpm_file) ---"
|
||||||
|
rpm -K "$rpm_file"
|
||||||
|
done
|
||||||
|
'
|
||||||
|
- name: Clean up GPG key
|
||||||
|
if: always()
|
||||||
|
run: rm -f /tmp/gpg-rpm-signing-key.asc
|
||||||
|
- name: Tag and push images (amd64 only)
|
||||||
|
id: tag_and_push_images
|
||||||
|
if: |
|
||||||
|
(github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository) ||
|
||||||
|
(github.event_name == 'push' && github.ref == 'refs/heads/main')
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
resolve_tags() {
|
||||||
|
if [[ "${{ github.event_name }}" == "pull_request" ]]; then
|
||||||
|
echo "pr-${{ github.event.pull_request.number }}"
|
||||||
|
else
|
||||||
|
echo "main sha-$(git rev-parse --short HEAD)"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
ghcr_package_url() {
|
||||||
|
local image="$1" package encoded_package
|
||||||
|
package="${image#ghcr.io/}"
|
||||||
|
package="${package#*/}"
|
||||||
|
package="${package%%:*}"
|
||||||
|
encoded_package="${package//\//%2F}"
|
||||||
|
echo "https://github.com/orgs/netbirdio/packages/container/package/${encoded_package}"
|
||||||
|
}
|
||||||
|
|
||||||
|
image_refs=()
|
||||||
|
|
||||||
|
tag_and_push() {
|
||||||
|
local src="$1" img_name tag dst
|
||||||
|
img_name="${src%%:*}"
|
||||||
|
for tag in $(resolve_tags); do
|
||||||
|
dst="${img_name}:${tag}"
|
||||||
|
echo "Tagging ${src} -> ${dst}"
|
||||||
|
docker tag "$src" "$dst"
|
||||||
|
docker push "$dst"
|
||||||
|
image_refs+=("$dst")
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
cat > /tmp/goreleaser-artifacts.json <<'JSON'
|
||||||
|
${{ steps.goreleaser.outputs.artifacts }}
|
||||||
|
JSON
|
||||||
|
|
||||||
|
mapfile -t src_images < <(
|
||||||
|
jq -r '.[] | select(.type == "Docker Image") | select(.goarch == "amd64") | .name | select(startswith("ghcr.io/"))' /tmp/goreleaser-artifacts.json
|
||||||
|
)
|
||||||
|
|
||||||
|
for src in "${src_images[@]}"; do
|
||||||
|
tag_and_push "$src"
|
||||||
|
done
|
||||||
|
|
||||||
|
{
|
||||||
|
echo "images_markdown<<EOF"
|
||||||
|
if [[ ${#image_refs[@]} -eq 0 ]]; then
|
||||||
|
echo "_No GHCR images were pushed._"
|
||||||
|
else
|
||||||
|
printf '%s\n' "${image_refs[@]}" | sort -u | while read -r image; do
|
||||||
|
printf -- '- [`%s`](%s)\n' "$image" "$(ghcr_package_url "$image")"
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
echo "EOF"
|
||||||
|
} >> "$GITHUB_OUTPUT"
|
||||||
- name: upload non tags for debug purposes
|
- name: upload non tags for debug purposes
|
||||||
|
id: upload_release
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: release
|
name: release
|
||||||
path: dist/
|
path: dist/
|
||||||
retention-days: 7
|
retention-days: 7
|
||||||
- name: upload linux packages
|
- name: upload linux packages
|
||||||
|
id: upload_linux_packages
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: linux-packages
|
name: linux-packages
|
||||||
path: dist/netbird_linux**
|
path: dist/netbird_linux**
|
||||||
retention-days: 7
|
retention-days: 7
|
||||||
- name: upload windows packages
|
- name: upload windows packages
|
||||||
|
id: upload_windows_packages
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: windows-packages
|
name: windows-packages
|
||||||
path: dist/netbird_windows**
|
path: dist/netbird_windows**
|
||||||
retention-days: 7
|
retention-days: 7
|
||||||
- name: upload macos packages
|
- name: upload macos packages
|
||||||
|
id: upload_macos_packages
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: macos-packages
|
name: macos-packages
|
||||||
@@ -118,6 +311,8 @@ jobs:
|
|||||||
|
|
||||||
release_ui:
|
release_ui:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
outputs:
|
||||||
|
release_ui_artifact_url: ${{ steps.upload_release_ui.outputs.artifact-url }}
|
||||||
steps:
|
steps:
|
||||||
- name: Parse semver string
|
- name: Parse semver string
|
||||||
id: semver_parser
|
id: semver_parser
|
||||||
@@ -136,7 +331,7 @@ jobs:
|
|||||||
- name: Set up Go
|
- name: Set up Go
|
||||||
uses: actions/setup-go@v5
|
uses: actions/setup-go@v5
|
||||||
with:
|
with:
|
||||||
go-version: "1.23"
|
go-version-file: "go.mod"
|
||||||
cache: false
|
cache: false
|
||||||
- name: Cache Go modules
|
- name: Cache Go modules
|
||||||
uses: actions/cache@v4
|
uses: actions/cache@v4
|
||||||
@@ -157,6 +352,14 @@ jobs:
|
|||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: sudo apt update && sudo apt install -y -q libappindicator3-dev gir1.2-appindicator3-0.1 libxxf86vm-dev gcc-mingw-w64-x86-64
|
run: sudo apt update && sudo apt install -y -q libappindicator3-dev gir1.2-appindicator3-0.1 libxxf86vm-dev gcc-mingw-w64-x86-64
|
||||||
|
|
||||||
|
- name: Decode GPG signing key
|
||||||
|
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
|
||||||
|
env:
|
||||||
|
GPG_RPM_PRIVATE_KEY: ${{ secrets.GPG_RPM_PRIVATE_KEY }}
|
||||||
|
run: |
|
||||||
|
echo "$GPG_RPM_PRIVATE_KEY" | base64 -d > /tmp/gpg-rpm-signing-key.asc
|
||||||
|
echo "GPG_RPM_KEY_FILE=/tmp/gpg-rpm-signing-key.asc" >> $GITHUB_ENV
|
||||||
|
|
||||||
- name: Install LLVM-MinGW for ARM64 cross-compilation
|
- name: Install LLVM-MinGW for ARM64 cross-compilation
|
||||||
run: |
|
run: |
|
||||||
cd /tmp
|
cd /tmp
|
||||||
@@ -181,7 +384,26 @@ jobs:
|
|||||||
HOMEBREW_TAP_GITHUB_TOKEN: ${{ secrets.HOMEBREW_TAP_GITHUB_TOKEN }}
|
HOMEBREW_TAP_GITHUB_TOKEN: ${{ secrets.HOMEBREW_TAP_GITHUB_TOKEN }}
|
||||||
UPLOAD_DEBIAN_SECRET: ${{ secrets.PKG_UPLOAD_SECRET }}
|
UPLOAD_DEBIAN_SECRET: ${{ secrets.PKG_UPLOAD_SECRET }}
|
||||||
UPLOAD_YUM_SECRET: ${{ secrets.PKG_UPLOAD_SECRET }}
|
UPLOAD_YUM_SECRET: ${{ secrets.PKG_UPLOAD_SECRET }}
|
||||||
|
GPG_RPM_KEY_FILE: ${{ env.GPG_RPM_KEY_FILE }}
|
||||||
|
NFPM_NETBIRD_UI_RPM_PASSPHRASE: ${{ secrets.GPG_RPM_PASSPHRASE }}
|
||||||
|
- name: Verify RPM signatures
|
||||||
|
run: |
|
||||||
|
docker run --rm -v $(pwd)/dist:/dist fedora:41 bash -c '
|
||||||
|
dnf install -y -q rpm-sign curl >/dev/null 2>&1
|
||||||
|
curl -sSL https://pkgs.netbird.io/yum/repodata/repomd.xml.key -o /tmp/rpm-pub.key
|
||||||
|
rpm --import /tmp/rpm-pub.key
|
||||||
|
echo "=== Verifying RPM signatures ==="
|
||||||
|
for rpm_file in /dist/*.rpm; do
|
||||||
|
[ -f "$rpm_file" ] || continue
|
||||||
|
echo "--- $(basename $rpm_file) ---"
|
||||||
|
rpm -K "$rpm_file"
|
||||||
|
done
|
||||||
|
'
|
||||||
|
- name: Clean up GPG key
|
||||||
|
if: always()
|
||||||
|
run: rm -f /tmp/gpg-rpm-signing-key.asc
|
||||||
- name: upload non tags for debug purposes
|
- name: upload non tags for debug purposes
|
||||||
|
id: upload_release_ui
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: release-ui
|
name: release-ui
|
||||||
@@ -190,6 +412,8 @@ jobs:
|
|||||||
|
|
||||||
release_ui_darwin:
|
release_ui_darwin:
|
||||||
runs-on: macos-latest
|
runs-on: macos-latest
|
||||||
|
outputs:
|
||||||
|
release_ui_darwin_artifact_url: ${{ steps.upload_release_ui_darwin.outputs.artifact-url }}
|
||||||
steps:
|
steps:
|
||||||
- if: ${{ !startsWith(github.ref, 'refs/tags/v') }}
|
- if: ${{ !startsWith(github.ref, 'refs/tags/v') }}
|
||||||
run: echo "flags=--snapshot" >> $GITHUB_ENV
|
run: echo "flags=--snapshot" >> $GITHUB_ENV
|
||||||
@@ -200,7 +424,7 @@ jobs:
|
|||||||
- name: Set up Go
|
- name: Set up Go
|
||||||
uses: actions/setup-go@v5
|
uses: actions/setup-go@v5
|
||||||
with:
|
with:
|
||||||
go-version: "1.23"
|
go-version-file: "go.mod"
|
||||||
cache: false
|
cache: false
|
||||||
- name: Cache Go modules
|
- name: Cache Go modules
|
||||||
uses: actions/cache@v4
|
uses: actions/cache@v4
|
||||||
@@ -224,15 +448,258 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
- name: upload non tags for debug purposes
|
- name: upload non tags for debug purposes
|
||||||
|
id: upload_release_ui_darwin
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: release-ui-darwin
|
name: release-ui-darwin
|
||||||
path: dist/
|
path: dist/
|
||||||
retention-days: 3
|
retention-days: 3
|
||||||
|
|
||||||
trigger_signer:
|
test_windows_installer:
|
||||||
|
name: "Windows Installer / Build Test"
|
||||||
|
runs-on: windows-2022
|
||||||
|
needs: [release, release_ui]
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- arch: amd64
|
||||||
|
wintun_arch: amd64
|
||||||
|
- arch: arm64
|
||||||
|
wintun_arch: arm64
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
shell: powershell
|
||||||
|
env:
|
||||||
|
PackageWorkdir: netbird_windows_${{ matrix.arch }}
|
||||||
|
downloadPath: '${{ github.workspace }}\temp'
|
||||||
|
steps:
|
||||||
|
- name: Parse semver string
|
||||||
|
id: semver_parser
|
||||||
|
uses: booxmedialtd/ws-action-parse-semver@v1
|
||||||
|
with:
|
||||||
|
input_string: ${{ (startsWith(github.ref, 'refs/tags/v') && github.ref) || 'refs/tags/v0.0.0' }}
|
||||||
|
version_extractor_regex: '\/v(.*)$'
|
||||||
|
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Add 7-Zip to PATH
|
||||||
|
run: echo "C:\Program Files\7-Zip" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append
|
||||||
|
|
||||||
|
- name: Download release artifacts
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
name: release
|
||||||
|
path: release
|
||||||
|
|
||||||
|
- name: Download UI release artifacts
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
name: release-ui
|
||||||
|
path: release-ui
|
||||||
|
|
||||||
|
- name: Stage binaries into dist
|
||||||
|
run: |
|
||||||
|
$workdir = "dist\${{ env.PackageWorkdir }}"
|
||||||
|
New-Item -ItemType Directory -Force -Path $workdir | Out-Null
|
||||||
|
$client = Get-ChildItem -Recurse -Path release -Filter "netbird_*_windows_${{ matrix.arch }}.tar.gz" | Select-Object -First 1
|
||||||
|
$ui = Get-ChildItem -Recurse -Path release-ui -Filter "netbird-ui-windows_*_windows_${{ matrix.arch }}.tar.gz" | Select-Object -First 1
|
||||||
|
if (-not $client) { Write-Host "::error::client tarball not found for ${{ matrix.arch }}"; exit 1 }
|
||||||
|
if (-not $ui) { Write-Host "::error::ui tarball not found for ${{ matrix.arch }}"; exit 1 }
|
||||||
|
Write-Host "Client: $($client.FullName)"
|
||||||
|
Write-Host "UI: $($ui.FullName)"
|
||||||
|
tar -zvxf $client.FullName -C $workdir
|
||||||
|
tar -zvxf $ui.FullName -C $workdir
|
||||||
|
Get-ChildItem $workdir
|
||||||
|
|
||||||
|
- name: Download wintun
|
||||||
|
uses: carlosperate/download-file-action@v2
|
||||||
|
id: download-wintun
|
||||||
|
with:
|
||||||
|
file-url: https://pkgs.netbird.io/wintun/wintun-0.14.1.zip
|
||||||
|
file-name: wintun.zip
|
||||||
|
location: ${{ env.downloadPath }}
|
||||||
|
sha256: '07c256185d6ee3652e09fa55c0b673e2624b565e02c4b9091c79ca7d2f24ef51'
|
||||||
|
|
||||||
|
- name: Decompress wintun files
|
||||||
|
run: tar -zvxf "${{ steps.download-wintun.outputs.file-path }}" -C ${{ env.downloadPath }}
|
||||||
|
|
||||||
|
- name: Move wintun.dll into dist
|
||||||
|
run: mv ${{ env.downloadPath }}\wintun\bin\${{ matrix.wintun_arch }}\wintun.dll ${{ github.workspace }}\dist\${{ env.PackageWorkdir }}\
|
||||||
|
|
||||||
|
- name: Download Mesa3D (amd64 only)
|
||||||
|
uses: carlosperate/download-file-action@v2
|
||||||
|
id: download-mesa3d
|
||||||
|
if: matrix.arch == 'amd64'
|
||||||
|
with:
|
||||||
|
file-url: https://downloads.fdossena.com/Projects/Mesa3D/Builds/MesaForWindows-x64-20.1.8.7z
|
||||||
|
file-name: mesa3d.7z
|
||||||
|
location: ${{ env.downloadPath }}
|
||||||
|
sha256: '71c7cb64ec229a1d6b8d62fa08e1889ed2bd17c0eeede8689daf0f25cb31d6b9'
|
||||||
|
|
||||||
|
- name: Extract Mesa3D driver (amd64 only)
|
||||||
|
if: matrix.arch == 'amd64'
|
||||||
|
run: 7z x -o"${{ env.downloadPath }}" "${{ env.downloadPath }}/mesa3d.7z"
|
||||||
|
|
||||||
|
- name: Move opengl32.dll into dist (amd64 only)
|
||||||
|
if: matrix.arch == 'amd64'
|
||||||
|
run: mv ${{ env.downloadPath }}\opengl32.dll ${{ github.workspace }}\dist\${{ env.PackageWorkdir }}\
|
||||||
|
|
||||||
|
- name: Download EnVar plugin for NSIS
|
||||||
|
uses: carlosperate/download-file-action@v2
|
||||||
|
with:
|
||||||
|
file-url: https://nsis.sourceforge.io/mediawiki/images/7/7f/EnVar_plugin.zip
|
||||||
|
file-name: envar_plugin.zip
|
||||||
|
location: ${{ github.workspace }}
|
||||||
|
|
||||||
|
- name: Extract EnVar plugin
|
||||||
|
run: 7z x -o"${{ github.workspace }}/NSIS_Plugins" "${{ github.workspace }}/envar_plugin.zip"
|
||||||
|
|
||||||
|
- name: Download ShellExecAsUser plugin for NSIS (amd64 only)
|
||||||
|
uses: carlosperate/download-file-action@v2
|
||||||
|
if: matrix.arch == 'amd64'
|
||||||
|
with:
|
||||||
|
file-url: https://nsis.sourceforge.io/mediawiki/images/6/68/ShellExecAsUser_amd64-Unicode.7z
|
||||||
|
file-name: ShellExecAsUser_amd64-Unicode.7z
|
||||||
|
location: ${{ github.workspace }}
|
||||||
|
|
||||||
|
- name: Extract ShellExecAsUser plugin (amd64 only)
|
||||||
|
if: matrix.arch == 'amd64'
|
||||||
|
run: 7z x -o"${{ github.workspace }}/NSIS_Plugins" "${{ github.workspace }}/ShellExecAsUser_amd64-Unicode.7z"
|
||||||
|
|
||||||
|
- name: Build NSIS installer
|
||||||
|
uses: joncloud/makensis-action@v3.3
|
||||||
|
with:
|
||||||
|
additional-plugin-paths: ${{ github.workspace }}/NSIS_Plugins/Plugins
|
||||||
|
script-file: client/installer.nsis
|
||||||
|
arguments: "/V4 /DARCH=${{ matrix.arch }}"
|
||||||
|
env:
|
||||||
|
APPVER: ${{ steps.semver_parser.outputs.major }}.${{ steps.semver_parser.outputs.minor }}.${{ steps.semver_parser.outputs.patch }}.${{ github.run_id }}
|
||||||
|
|
||||||
|
- name: Rename NSIS installer
|
||||||
|
run: mv netbird-installer.exe netbird_installer_test_windows_${{ matrix.arch }}.exe
|
||||||
|
|
||||||
|
- name: Install WiX
|
||||||
|
run: |
|
||||||
|
dotnet tool install --global wix --version 6.0.2
|
||||||
|
wix extension add WixToolset.Util.wixext/6.0.2
|
||||||
|
|
||||||
|
- name: Build MSI installer
|
||||||
|
env:
|
||||||
|
NETBIRD_VERSION: "${{ steps.semver_parser.outputs.fullversion }}"
|
||||||
|
run: wix build -arch ${{ matrix.arch == 'amd64' && 'x64' || 'arm64' }} -ext WixToolset.Util.wixext -o netbird_installer_test_windows_${{ matrix.arch }}.msi .\client\netbird.wxs -d ProcessorArchitecture=${{ matrix.arch == 'amd64' && 'x64' || 'arm64' }} -d ArchSuffix=${{ matrix.arch }}
|
||||||
|
|
||||||
|
- name: Upload installer artifacts
|
||||||
|
if: always()
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: windows-installer-test-${{ matrix.arch }}
|
||||||
|
path: |
|
||||||
|
netbird_installer_test_windows_${{ matrix.arch }}.exe
|
||||||
|
netbird_installer_test_windows_${{ matrix.arch }}.msi
|
||||||
|
retention-days: 3
|
||||||
|
|
||||||
|
comment_release_artifacts:
|
||||||
|
name: Comment release artifacts
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: [release, release_ui, release_ui_darwin]
|
needs: [release, release_ui, release_ui_darwin]
|
||||||
|
if: ${{ always() && github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository }}
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
issues: write
|
||||||
|
pull-requests: write
|
||||||
|
steps:
|
||||||
|
- name: Create or update PR comment
|
||||||
|
uses: actions/github-script@v7
|
||||||
|
env:
|
||||||
|
RELEASE_RESULT: ${{ needs.release.result }}
|
||||||
|
RELEASE_UI_RESULT: ${{ needs.release_ui.result }}
|
||||||
|
RELEASE_UI_DARWIN_RESULT: ${{ needs.release_ui_darwin.result }}
|
||||||
|
RELEASE_ARTIFACT_URL: ${{ needs.release.outputs.release_artifact_url }}
|
||||||
|
LINUX_PACKAGES_ARTIFACT_URL: ${{ needs.release.outputs.linux_packages_artifact_url }}
|
||||||
|
WINDOWS_PACKAGES_ARTIFACT_URL: ${{ needs.release.outputs.windows_packages_artifact_url }}
|
||||||
|
MACOS_PACKAGES_ARTIFACT_URL: ${{ needs.release.outputs.macos_packages_artifact_url }}
|
||||||
|
RELEASE_UI_ARTIFACT_URL: ${{ needs.release_ui.outputs.release_ui_artifact_url }}
|
||||||
|
RELEASE_UI_DARWIN_ARTIFACT_URL: ${{ needs.release_ui_darwin.outputs.release_ui_darwin_artifact_url }}
|
||||||
|
GHCR_IMAGES_MARKDOWN: ${{ needs.release.outputs.ghcr_images }}
|
||||||
|
with:
|
||||||
|
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
script: |
|
||||||
|
const marker = '<!-- netbird-release-artifacts -->';
|
||||||
|
const { owner, repo } = context.repo;
|
||||||
|
const issue_number = context.payload.pull_request.number;
|
||||||
|
const runUrl = `${context.serverUrl}/${owner}/${repo}/actions/runs/${context.runId}`;
|
||||||
|
const shortSha = context.payload.pull_request.head.sha.slice(0, 7);
|
||||||
|
|
||||||
|
const artifactCell = (url, result) => {
|
||||||
|
if (url) return `[Download](${url})`;
|
||||||
|
return result && result !== 'success' ? `_Not available (${result})_` : '_Not available_';
|
||||||
|
};
|
||||||
|
|
||||||
|
const artifacts = [
|
||||||
|
['All release artifacts', process.env.RELEASE_ARTIFACT_URL, process.env.RELEASE_RESULT],
|
||||||
|
['Linux packages', process.env.LINUX_PACKAGES_ARTIFACT_URL, process.env.RELEASE_RESULT],
|
||||||
|
['Windows packages', process.env.WINDOWS_PACKAGES_ARTIFACT_URL, process.env.RELEASE_RESULT],
|
||||||
|
['macOS packages', process.env.MACOS_PACKAGES_ARTIFACT_URL, process.env.RELEASE_RESULT],
|
||||||
|
['UI artifacts', process.env.RELEASE_UI_ARTIFACT_URL, process.env.RELEASE_UI_RESULT],
|
||||||
|
['UI macOS artifacts', process.env.RELEASE_UI_DARWIN_ARTIFACT_URL, process.env.RELEASE_UI_DARWIN_RESULT],
|
||||||
|
];
|
||||||
|
|
||||||
|
const artifactRows = artifacts
|
||||||
|
.map(([name, url, result]) => `| ${name} | ${artifactCell(url, result)} |`)
|
||||||
|
.join('\n');
|
||||||
|
|
||||||
|
const ghcrImages = (process.env.GHCR_IMAGES_MARKDOWN || '').trim() || '_No GHCR images were pushed._';
|
||||||
|
|
||||||
|
const body = [
|
||||||
|
marker,
|
||||||
|
'## Release artifacts',
|
||||||
|
'',
|
||||||
|
`Built for PR head \`${shortSha}\` in [workflow run #${process.env.GITHUB_RUN_NUMBER}](${runUrl}).`,
|
||||||
|
'',
|
||||||
|
'| Artifact | Link |',
|
||||||
|
'| --- | --- |',
|
||||||
|
artifactRows,
|
||||||
|
'',
|
||||||
|
'### GHCR images (amd64)',
|
||||||
|
ghcrImages,
|
||||||
|
'',
|
||||||
|
'_This comment is updated by the Release workflow. Artifact links expire according to the workflow retention policy._',
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
const comments = await github.paginate(github.rest.issues.listComments, {
|
||||||
|
owner,
|
||||||
|
repo,
|
||||||
|
issue_number,
|
||||||
|
per_page: 100,
|
||||||
|
});
|
||||||
|
|
||||||
|
const previous = comments.find(comment =>
|
||||||
|
comment.user?.type === 'Bot' && comment.body?.includes(marker)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (previous) {
|
||||||
|
await github.rest.issues.updateComment({
|
||||||
|
owner,
|
||||||
|
repo,
|
||||||
|
comment_id: previous.id,
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
core.info(`Updated release artifacts comment ${previous.id}`);
|
||||||
|
} else {
|
||||||
|
const { data } = await github.rest.issues.createComment({
|
||||||
|
owner,
|
||||||
|
repo,
|
||||||
|
issue_number,
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
core.info(`Created release artifacts comment ${data.id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
trigger_signer:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: [release, release_ui, release_ui_darwin, test_windows_installer]
|
||||||
if: startsWith(github.ref, 'refs/tags/')
|
if: startsWith(github.ref, 'refs/tags/')
|
||||||
steps:
|
steps:
|
||||||
- name: Trigger binaries sign pipelines
|
- name: Trigger binaries sign pipelines
|
||||||
|
|||||||
28
.github/workflows/sync-tag.yml
vendored
28
.github/workflows/sync-tag.yml
vendored
@@ -9,6 +9,8 @@ concurrency:
|
|||||||
group: ${{ github.workflow }}-${{ github.ref }}-${{ github.head_ref || github.actor_id }}
|
group: ${{ github.workflow }}-${{ github.ref }}-${{ github.head_ref || github.actor_id }}
|
||||||
cancel-in-progress: true
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
# Receiving workflows (cloud sync-tag, mobile bump-netbird) expect the short
|
||||||
|
# tag form (e.g. v0.30.0), not refs/tags/v0.30.0 — github.ref_name, not github.ref.
|
||||||
jobs:
|
jobs:
|
||||||
trigger_sync_tag:
|
trigger_sync_tag:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -20,4 +22,30 @@ jobs:
|
|||||||
ref: main
|
ref: main
|
||||||
repo: ${{ secrets.UPSTREAM_REPO }}
|
repo: ${{ secrets.UPSTREAM_REPO }}
|
||||||
token: ${{ secrets.NC_GITHUB_TOKEN }}
|
token: ${{ secrets.NC_GITHUB_TOKEN }}
|
||||||
|
inputs: '{ "tag": "${{ github.ref_name }}" }'
|
||||||
|
|
||||||
|
trigger_android_bump:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
if: github.event.created && !github.event.deleted && startsWith(github.ref, 'refs/tags/v') && !contains(github.ref_name, '-')
|
||||||
|
steps:
|
||||||
|
- name: Trigger android-client submodule bump
|
||||||
|
uses: benc-uk/workflow-dispatch@7a027648b88c2413826b6ddd6c76114894dc5ec4 # v1.3.1
|
||||||
|
with:
|
||||||
|
workflow: bump-netbird.yml
|
||||||
|
ref: main
|
||||||
|
repo: netbirdio/android-client
|
||||||
|
token: ${{ secrets.NC_GITHUB_TOKEN }}
|
||||||
|
inputs: '{ "tag": "${{ github.ref_name }}" }'
|
||||||
|
|
||||||
|
trigger_ios_bump:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
if: github.event.created && !github.event.deleted && startsWith(github.ref, 'refs/tags/v') && !contains(github.ref_name, '-')
|
||||||
|
steps:
|
||||||
|
- name: Trigger ios-client submodule bump
|
||||||
|
uses: benc-uk/workflow-dispatch@7a027648b88c2413826b6ddd6c76114894dc5ec4 # v1.3.1
|
||||||
|
with:
|
||||||
|
workflow: bump-netbird.yml
|
||||||
|
ref: main
|
||||||
|
repo: netbirdio/ios-client
|
||||||
|
token: ${{ secrets.NC_GITHUB_TOKEN }}
|
||||||
inputs: '{ "tag": "${{ github.ref_name }}" }'
|
inputs: '{ "tag": "${{ github.ref_name }}" }'
|
||||||
@@ -67,10 +67,13 @@ jobs:
|
|||||||
- name: Install curl
|
- name: Install curl
|
||||||
run: sudo apt-get install -y curl
|
run: sudo apt-get install -y curl
|
||||||
|
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Install Go
|
- name: Install Go
|
||||||
uses: actions/setup-go@v5
|
uses: actions/setup-go@v5
|
||||||
with:
|
with:
|
||||||
go-version: "1.23.x"
|
go-version-file: "go.mod"
|
||||||
|
|
||||||
- name: Cache Go modules
|
- name: Cache Go modules
|
||||||
uses: actions/cache@v4
|
uses: actions/cache@v4
|
||||||
@@ -80,9 +83,6 @@ jobs:
|
|||||||
restore-keys: |
|
restore-keys: |
|
||||||
${{ runner.os }}-go-
|
${{ runner.os }}-go-
|
||||||
|
|
||||||
- name: Checkout code
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Setup MySQL privileges
|
- name: Setup MySQL privileges
|
||||||
if: matrix.store == 'mysql'
|
if: matrix.store == 'mysql'
|
||||||
run: |
|
run: |
|
||||||
@@ -243,6 +243,7 @@ jobs:
|
|||||||
working-directory: infrastructure_files/artifacts
|
working-directory: infrastructure_files/artifacts
|
||||||
run: |
|
run: |
|
||||||
sleep 30
|
sleep 30
|
||||||
|
docker compose logs
|
||||||
docker compose exec management ls -l /var/lib/netbird/ | grep -i GeoLite2-City_[0-9]*.mmdb
|
docker compose exec management ls -l /var/lib/netbird/ | grep -i GeoLite2-City_[0-9]*.mmdb
|
||||||
docker compose exec management ls -l /var/lib/netbird/ | grep -i geonames_[0-9]*.db
|
docker compose exec management ls -l /var/lib/netbird/ | grep -i geonames_[0-9]*.db
|
||||||
|
|
||||||
|
|||||||
21
.github/workflows/wasm-build-validation.yml
vendored
21
.github/workflows/wasm-build-validation.yml
vendored
@@ -14,26 +14,27 @@ jobs:
|
|||||||
js_lint:
|
js_lint:
|
||||||
name: "JS / Lint"
|
name: "JS / Lint"
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
env:
|
||||||
|
GOOS: js
|
||||||
|
GOARCH: wasm
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
- name: Install Go
|
- name: Install Go
|
||||||
uses: actions/setup-go@v5
|
uses: actions/setup-go@v5
|
||||||
with:
|
with:
|
||||||
go-version: "1.23.x"
|
go-version-file: "go.mod"
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: sudo apt update && sudo apt install -y -q libgtk-3-dev libayatana-appindicator3-dev libgl1-mesa-dev xorg-dev libpcap-dev
|
run: sudo apt update && sudo apt install -y -q libgtk-3-dev libayatana-appindicator3-dev libgl1-mesa-dev xorg-dev libpcap-dev
|
||||||
- name: Install golangci-lint
|
- name: Install golangci-lint
|
||||||
uses: golangci/golangci-lint-action@d6238b002a20823d52840fda27e2d4891c5952dc
|
uses: golangci/golangci-lint-action@4afd733a84b1f43292c63897423277bb7f4313a9 # v8.0.0
|
||||||
with:
|
with:
|
||||||
version: latest
|
version: latest
|
||||||
install-mode: binary
|
install-mode: binary
|
||||||
skip-cache: true
|
skip-cache: true
|
||||||
skip-pkg-cache: true
|
skip-save-cache: true
|
||||||
skip-build-cache: true
|
cache-invalidation-interval: 0
|
||||||
- name: Run golangci-lint for WASM
|
working-directory: ./client
|
||||||
run: |
|
|
||||||
GOOS=js GOARCH=wasm golangci-lint run --timeout=12m --out-format colored-line-number ./client/...
|
|
||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
|
|
||||||
js_build:
|
js_build:
|
||||||
@@ -45,7 +46,7 @@ jobs:
|
|||||||
- name: Install Go
|
- name: Install Go
|
||||||
uses: actions/setup-go@v5
|
uses: actions/setup-go@v5
|
||||||
with:
|
with:
|
||||||
go-version: "1.23.x"
|
go-version-file: "go.mod"
|
||||||
- name: Build Wasm client
|
- name: Build Wasm client
|
||||||
run: GOOS=js GOARCH=wasm go build -o netbird.wasm ./client/wasm/cmd
|
run: GOOS=js GOARCH=wasm go build -o netbird.wasm ./client/wasm/cmd
|
||||||
env:
|
env:
|
||||||
@@ -60,8 +61,8 @@ jobs:
|
|||||||
|
|
||||||
echo "Size: ${SIZE} bytes (${SIZE_MB} MB)"
|
echo "Size: ${SIZE} bytes (${SIZE_MB} MB)"
|
||||||
|
|
||||||
if [ ${SIZE} -gt 52428800 ]; then
|
if [ ${SIZE} -gt 58720256 ]; then
|
||||||
echo "Wasm binary size (${SIZE_MB}MB) exceeds 50MB limit!"
|
echo "Wasm binary size (${SIZE_MB}MB) exceeds 56MB limit!"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -2,6 +2,7 @@
|
|||||||
.run
|
.run
|
||||||
*.iml
|
*.iml
|
||||||
dist/
|
dist/
|
||||||
|
!proxy/web/dist/
|
||||||
bin/
|
bin/
|
||||||
.env
|
.env
|
||||||
conf.json
|
conf.json
|
||||||
@@ -31,3 +32,5 @@ infrastructure_files/setup-*.env
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
vendor/
|
vendor/
|
||||||
/netbird
|
/netbird
|
||||||
|
client/netbird-electron/
|
||||||
|
management/server/types/testdata/
|
||||||
|
|||||||
260
.golangci.yaml
260
.golangci.yaml
@@ -1,139 +1,129 @@
|
|||||||
run:
|
version: "2"
|
||||||
# Timeout for analysis, e.g. 30s, 5m.
|
|
||||||
# Default: 1m
|
|
||||||
timeout: 6m
|
|
||||||
|
|
||||||
# This file contains only configs which differ from defaults.
|
|
||||||
# All possible options can be found here https://github.com/golangci/golangci-lint/blob/master/.golangci.reference.yml
|
|
||||||
linters-settings:
|
|
||||||
errcheck:
|
|
||||||
# Report about not checking of errors in type assertions: `a := b.(MyStruct)`.
|
|
||||||
# Such cases aren't reported by default.
|
|
||||||
# Default: false
|
|
||||||
check-type-assertions: false
|
|
||||||
|
|
||||||
gosec:
|
|
||||||
includes:
|
|
||||||
- G101 # Look for hard coded credentials
|
|
||||||
#- G102 # Bind to all interfaces
|
|
||||||
- G103 # Audit the use of unsafe block
|
|
||||||
- G104 # Audit errors not checked
|
|
||||||
- G106 # Audit the use of ssh.InsecureIgnoreHostKey
|
|
||||||
#- G107 # Url provided to HTTP request as taint input
|
|
||||||
- G108 # Profiling endpoint automatically exposed on /debug/pprof
|
|
||||||
- G109 # Potential Integer overflow made by strconv.Atoi result conversion to int16/32
|
|
||||||
- G110 # Potential DoS vulnerability via decompression bomb
|
|
||||||
- G111 # Potential directory traversal
|
|
||||||
#- G112 # Potential slowloris attack
|
|
||||||
- G113 # Usage of Rat.SetString in math/big with an overflow (CVE-2022-23772)
|
|
||||||
#- G114 # Use of net/http serve function that has no support for setting timeouts
|
|
||||||
- G201 # SQL query construction using format string
|
|
||||||
- G202 # SQL query construction using string concatenation
|
|
||||||
- G203 # Use of unescaped data in HTML templates
|
|
||||||
#- G204 # Audit use of command execution
|
|
||||||
- G301 # Poor file permissions used when creating a directory
|
|
||||||
- G302 # Poor file permissions used with chmod
|
|
||||||
- G303 # Creating tempfile using a predictable path
|
|
||||||
- G304 # File path provided as taint input
|
|
||||||
- G305 # File traversal when extracting zip/tar archive
|
|
||||||
- G306 # Poor file permissions used when writing to a new file
|
|
||||||
- G307 # Poor file permissions used when creating a file with os.Create
|
|
||||||
#- G401 # Detect the usage of DES, RC4, MD5 or SHA1
|
|
||||||
#- G402 # Look for bad TLS connection settings
|
|
||||||
- G403 # Ensure minimum RSA key length of 2048 bits
|
|
||||||
#- G404 # Insecure random number source (rand)
|
|
||||||
#- G501 # Import blocklist: crypto/md5
|
|
||||||
- G502 # Import blocklist: crypto/des
|
|
||||||
- G503 # Import blocklist: crypto/rc4
|
|
||||||
- G504 # Import blocklist: net/http/cgi
|
|
||||||
#- G505 # Import blocklist: crypto/sha1
|
|
||||||
- G601 # Implicit memory aliasing of items from a range statement
|
|
||||||
- G602 # Slice access out of bounds
|
|
||||||
|
|
||||||
gocritic:
|
|
||||||
disabled-checks:
|
|
||||||
- commentFormatting
|
|
||||||
- captLocal
|
|
||||||
- deprecatedComment
|
|
||||||
|
|
||||||
govet:
|
|
||||||
# Enable all analyzers.
|
|
||||||
# Default: false
|
|
||||||
enable-all: false
|
|
||||||
enable:
|
|
||||||
- nilness
|
|
||||||
|
|
||||||
revive:
|
|
||||||
rules:
|
|
||||||
- name: exported
|
|
||||||
severity: warning
|
|
||||||
disabled: false
|
|
||||||
arguments:
|
|
||||||
- "checkPrivateReceivers"
|
|
||||||
- "sayRepetitiveInsteadOfStutters"
|
|
||||||
tenv:
|
|
||||||
# The option `all` will run against whole test files (`_test.go`) regardless of method/function signatures.
|
|
||||||
# Otherwise, only methods that take `*testing.T`, `*testing.B`, and `testing.TB` as arguments are checked.
|
|
||||||
# Default: false
|
|
||||||
all: true
|
|
||||||
|
|
||||||
linters:
|
linters:
|
||||||
disable-all: true
|
default: none
|
||||||
enable:
|
enable:
|
||||||
## enabled by default
|
- bodyclose
|
||||||
- errcheck # checking for unchecked errors, these unchecked errors can be critical bugs in some cases
|
- dupword
|
||||||
- gosimple # specializes in simplifying a code
|
- durationcheck
|
||||||
- govet # reports suspicious constructs, such as Printf calls whose arguments do not align with the format string
|
- errcheck
|
||||||
- ineffassign # detects when assignments to existing variables are not used
|
- forbidigo
|
||||||
- staticcheck # is a go vet on steroids, applying a ton of static analysis checks
|
- gocritic
|
||||||
- tenv # Tenv is analyzer that detects using os.Setenv instead of t.Setenv since Go1.17.
|
- gosec
|
||||||
- typecheck # like the front-end of a Go compiler, parses and type-checks Go code
|
- govet
|
||||||
- unused # checks for unused constants, variables, functions and types
|
- ineffassign
|
||||||
## disable by default but the have interesting results so lets add them
|
- mirror
|
||||||
- bodyclose # checks whether HTTP response body is closed successfully
|
- misspell
|
||||||
- dupword # dupword checks for duplicate words in the source code
|
- nilerr
|
||||||
- durationcheck # durationcheck checks for two durations multiplied together
|
- nilnil
|
||||||
- forbidigo # forbidigo forbids identifiers
|
- predeclared
|
||||||
- gocritic # provides diagnostics that check for bugs, performance and style issues
|
- revive
|
||||||
- gosec # inspects source code for security problems
|
- sqlclosecheck
|
||||||
- mirror # mirror reports wrong mirror patterns of bytes/strings usage
|
- staticcheck
|
||||||
- misspell # misspess finds commonly misspelled English words in comments
|
- unused
|
||||||
- nilerr # finds the code that returns nil even if it checks that the error is not nil
|
- wastedassign
|
||||||
- nilnil # checks that there is no simultaneous return of nil error and an invalid value
|
settings:
|
||||||
- predeclared # predeclared finds code that shadows one of Go's predeclared identifiers
|
errcheck:
|
||||||
- revive # Fast, configurable, extensible, flexible, and beautiful linter for Go. Drop-in replacement of golint.
|
check-type-assertions: false
|
||||||
- sqlclosecheck # checks that sql.Rows and sql.Stmt are closed
|
gocritic:
|
||||||
# - thelper # thelper detects Go test helpers without t.Helper() call and checks the consistency of test helpers.
|
disabled-checks:
|
||||||
- wastedassign # wastedassign finds wasted assignment statements
|
- commentFormatting
|
||||||
|
- captLocal
|
||||||
|
- deprecatedComment
|
||||||
|
gosec:
|
||||||
|
includes:
|
||||||
|
- G101
|
||||||
|
- G103
|
||||||
|
- G104
|
||||||
|
- G106
|
||||||
|
- G108
|
||||||
|
- G109
|
||||||
|
- G110
|
||||||
|
- G111
|
||||||
|
- G201
|
||||||
|
- G202
|
||||||
|
- G203
|
||||||
|
- G301
|
||||||
|
- G302
|
||||||
|
- G303
|
||||||
|
- G304
|
||||||
|
- G305
|
||||||
|
- G306
|
||||||
|
- G307
|
||||||
|
- G403
|
||||||
|
- G502
|
||||||
|
- G503
|
||||||
|
- G504
|
||||||
|
- G601
|
||||||
|
- G602
|
||||||
|
govet:
|
||||||
|
enable:
|
||||||
|
- nilness
|
||||||
|
disable:
|
||||||
|
# The inline analyzer flags x/exp/maps Clone/Clear with //go:fix inline
|
||||||
|
# directives but cannot perform the rewrite due to generic type
|
||||||
|
# parameter inference limitations in the Go inliner.
|
||||||
|
- inline
|
||||||
|
enable-all: false
|
||||||
|
revive:
|
||||||
|
rules:
|
||||||
|
- name: exported
|
||||||
|
arguments:
|
||||||
|
- checkPrivateReceivers
|
||||||
|
- sayRepetitiveInsteadOfStutters
|
||||||
|
severity: warning
|
||||||
|
disabled: false
|
||||||
|
exclusions:
|
||||||
|
generated: lax
|
||||||
|
presets:
|
||||||
|
- comments
|
||||||
|
- common-false-positives
|
||||||
|
- legacy
|
||||||
|
- std-error-handling
|
||||||
|
rules:
|
||||||
|
- linters:
|
||||||
|
- forbidigo
|
||||||
|
path: management/cmd/root\.go
|
||||||
|
- linters:
|
||||||
|
- forbidigo
|
||||||
|
path: signal/cmd/root\.go
|
||||||
|
- linters:
|
||||||
|
- unused
|
||||||
|
path: sharedsock/filter\.go
|
||||||
|
- linters:
|
||||||
|
- unused
|
||||||
|
path: client/firewall/iptables/rule\.go
|
||||||
|
- linters:
|
||||||
|
- gosec
|
||||||
|
- mirror
|
||||||
|
path: test\.go
|
||||||
|
- linters:
|
||||||
|
- nilnil
|
||||||
|
path: mock\.go
|
||||||
|
- linters:
|
||||||
|
- staticcheck
|
||||||
|
text: grpc.DialContext is deprecated
|
||||||
|
- linters:
|
||||||
|
- staticcheck
|
||||||
|
text: grpc.WithBlock is deprecated
|
||||||
|
- linters:
|
||||||
|
- staticcheck
|
||||||
|
text: "QF1001"
|
||||||
|
- linters:
|
||||||
|
- staticcheck
|
||||||
|
text: "QF1008"
|
||||||
|
- linters:
|
||||||
|
- staticcheck
|
||||||
|
text: "QF1012"
|
||||||
|
paths:
|
||||||
|
- third_party$
|
||||||
|
- builtin$
|
||||||
|
- examples$
|
||||||
issues:
|
issues:
|
||||||
# Maximum count of issues with the same text.
|
|
||||||
# Set to 0 to disable.
|
|
||||||
# Default: 3
|
|
||||||
max-same-issues: 5
|
max-same-issues: 5
|
||||||
|
formatters:
|
||||||
exclude-rules:
|
exclusions:
|
||||||
# allow fmt
|
generated: lax
|
||||||
- path: management/cmd/root\.go
|
paths:
|
||||||
linters: forbidigo
|
- third_party$
|
||||||
- path: signal/cmd/root\.go
|
- builtin$
|
||||||
linters: forbidigo
|
- examples$
|
||||||
- path: sharedsock/filter\.go
|
|
||||||
linters:
|
|
||||||
- unused
|
|
||||||
- path: client/firewall/iptables/rule\.go
|
|
||||||
linters:
|
|
||||||
- unused
|
|
||||||
- path: test\.go
|
|
||||||
linters:
|
|
||||||
- mirror
|
|
||||||
- gosec
|
|
||||||
- path: mock\.go
|
|
||||||
linters:
|
|
||||||
- nilnil
|
|
||||||
# Exclude specific deprecation warnings for grpc methods
|
|
||||||
- linters:
|
|
||||||
- staticcheck
|
|
||||||
text: "grpc.DialContext is deprecated"
|
|
||||||
- linters:
|
|
||||||
- staticcheck
|
|
||||||
text: "grpc.WithBlock is deprecated"
|
|
||||||
|
|||||||
222
.goreleaser.yaml
222
.goreleaser.yaml
@@ -106,6 +106,26 @@ builds:
|
|||||||
- -s -w -X github.com/netbirdio/netbird/version.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.CommitDate}} -X main.builtBy=goreleaser
|
- -s -w -X github.com/netbirdio/netbird/version.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.CommitDate}} -X main.builtBy=goreleaser
|
||||||
mod_timestamp: "{{ .CommitTimestamp }}"
|
mod_timestamp: "{{ .CommitTimestamp }}"
|
||||||
|
|
||||||
|
- id: netbird-server
|
||||||
|
dir: combined
|
||||||
|
env:
|
||||||
|
- CGO_ENABLED=1
|
||||||
|
- >-
|
||||||
|
{{- if eq .Runtime.Goos "linux" }}
|
||||||
|
{{- if eq .Arch "arm64"}}CC=aarch64-linux-gnu-gcc{{- end }}
|
||||||
|
{{- if eq .Arch "arm"}}CC=arm-linux-gnueabihf-gcc{{- end }}
|
||||||
|
{{- end }}
|
||||||
|
binary: netbird-server
|
||||||
|
goos:
|
||||||
|
- linux
|
||||||
|
goarch:
|
||||||
|
- amd64
|
||||||
|
- arm64
|
||||||
|
- arm
|
||||||
|
ldflags:
|
||||||
|
- -s -w -X github.com/netbirdio/netbird/version.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.CommitDate}} -X main.builtBy=goreleaser
|
||||||
|
mod_timestamp: "{{ .CommitTimestamp }}"
|
||||||
|
|
||||||
- id: netbird-upload
|
- id: netbird-upload
|
||||||
dir: upload-server
|
dir: upload-server
|
||||||
env: [CGO_ENABLED=0]
|
env: [CGO_ENABLED=0]
|
||||||
@@ -120,6 +140,40 @@ builds:
|
|||||||
- -s -w -X github.com/netbirdio/netbird/version.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.CommitDate}} -X main.builtBy=goreleaser
|
- -s -w -X github.com/netbirdio/netbird/version.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.CommitDate}} -X main.builtBy=goreleaser
|
||||||
mod_timestamp: "{{ .CommitTimestamp }}"
|
mod_timestamp: "{{ .CommitTimestamp }}"
|
||||||
|
|
||||||
|
- id: netbird-proxy
|
||||||
|
dir: proxy/cmd/proxy
|
||||||
|
env: [CGO_ENABLED=0]
|
||||||
|
binary: netbird-proxy
|
||||||
|
goos:
|
||||||
|
- linux
|
||||||
|
goarch:
|
||||||
|
- amd64
|
||||||
|
- arm64
|
||||||
|
- arm
|
||||||
|
ldflags:
|
||||||
|
- -s -w -X main.Version={{.Version}} -X main.Commit={{.Commit}} -X main.BuildDate={{.CommitDate}}
|
||||||
|
mod_timestamp: "{{ .CommitTimestamp }}"
|
||||||
|
|
||||||
|
- id: netbird-idp-migrate
|
||||||
|
dir: tools/idp-migrate
|
||||||
|
env:
|
||||||
|
- CGO_ENABLED=1
|
||||||
|
- >-
|
||||||
|
{{- if eq .Runtime.Goos "linux" }}
|
||||||
|
{{- if eq .Arch "arm64"}}CC=aarch64-linux-gnu-gcc{{- end }}
|
||||||
|
{{- if eq .Arch "arm"}}CC=arm-linux-gnueabihf-gcc{{- end }}
|
||||||
|
{{- end }}
|
||||||
|
binary: netbird-idp-migrate
|
||||||
|
goos:
|
||||||
|
- linux
|
||||||
|
goarch:
|
||||||
|
- amd64
|
||||||
|
- arm64
|
||||||
|
- arm
|
||||||
|
ldflags:
|
||||||
|
- -s -w -X github.com/netbirdio/netbird/version.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.CommitDate}} -X main.builtBy=goreleaser
|
||||||
|
mod_timestamp: "{{ .CommitTimestamp }}"
|
||||||
|
|
||||||
universal_binaries:
|
universal_binaries:
|
||||||
- id: netbird
|
- id: netbird
|
||||||
|
|
||||||
@@ -132,18 +186,22 @@ archives:
|
|||||||
- netbird-wasm
|
- netbird-wasm
|
||||||
name_template: "{{ .ProjectName }}_{{ .Version }}"
|
name_template: "{{ .ProjectName }}_{{ .Version }}"
|
||||||
format: binary
|
format: binary
|
||||||
|
- id: netbird-idp-migrate
|
||||||
|
builds:
|
||||||
|
- netbird-idp-migrate
|
||||||
|
name_template: "netbird-idp-migrate_{{ .Version }}_{{ .Os }}_{{ .Arch }}"
|
||||||
|
|
||||||
nfpms:
|
nfpms:
|
||||||
- maintainer: Netbird <dev@netbird.io>
|
- maintainer: Netbird <dev@netbird.io>
|
||||||
description: Netbird client.
|
description: Netbird client.
|
||||||
homepage: https://netbird.io/
|
homepage: https://netbird.io/
|
||||||
id: netbird-deb
|
license: BSD-3-Clause
|
||||||
|
id: netbird_deb
|
||||||
bindir: /usr/bin
|
bindir: /usr/bin
|
||||||
builds:
|
builds:
|
||||||
- netbird
|
- netbird
|
||||||
formats:
|
formats:
|
||||||
- deb
|
- deb
|
||||||
|
|
||||||
scripts:
|
scripts:
|
||||||
postinstall: "release_files/post_install.sh"
|
postinstall: "release_files/post_install.sh"
|
||||||
preremove: "release_files/pre_remove.sh"
|
preremove: "release_files/pre_remove.sh"
|
||||||
@@ -151,16 +209,19 @@ nfpms:
|
|||||||
- maintainer: Netbird <dev@netbird.io>
|
- maintainer: Netbird <dev@netbird.io>
|
||||||
description: Netbird client.
|
description: Netbird client.
|
||||||
homepage: https://netbird.io/
|
homepage: https://netbird.io/
|
||||||
id: netbird-rpm
|
license: BSD-3-Clause
|
||||||
|
id: netbird_rpm
|
||||||
bindir: /usr/bin
|
bindir: /usr/bin
|
||||||
builds:
|
builds:
|
||||||
- netbird
|
- netbird
|
||||||
formats:
|
formats:
|
||||||
- rpm
|
- rpm
|
||||||
|
|
||||||
scripts:
|
scripts:
|
||||||
postinstall: "release_files/post_install.sh"
|
postinstall: "release_files/post_install.sh"
|
||||||
preremove: "release_files/pre_remove.sh"
|
preremove: "release_files/pre_remove.sh"
|
||||||
|
rpm:
|
||||||
|
signature:
|
||||||
|
key_file: '{{ if index .Env "GPG_RPM_KEY_FILE" }}{{ .Env.GPG_RPM_KEY_FILE }}{{ end }}'
|
||||||
dockers:
|
dockers:
|
||||||
- image_templates:
|
- image_templates:
|
||||||
- netbirdio/netbird:{{ .Version }}-amd64
|
- netbirdio/netbird:{{ .Version }}-amd64
|
||||||
@@ -520,6 +581,104 @@ dockers:
|
|||||||
- "--label=org.opencontainers.image.revision={{.FullCommit}}"
|
- "--label=org.opencontainers.image.revision={{.FullCommit}}"
|
||||||
- "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}"
|
- "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}"
|
||||||
- "--label=maintainer=dev@netbird.io"
|
- "--label=maintainer=dev@netbird.io"
|
||||||
|
- image_templates:
|
||||||
|
- netbirdio/netbird-server:{{ .Version }}-amd64
|
||||||
|
- ghcr.io/netbirdio/netbird-server:{{ .Version }}-amd64
|
||||||
|
ids:
|
||||||
|
- netbird-server
|
||||||
|
goarch: amd64
|
||||||
|
use: buildx
|
||||||
|
dockerfile: combined/Dockerfile
|
||||||
|
build_flag_templates:
|
||||||
|
- "--platform=linux/amd64"
|
||||||
|
- "--label=org.opencontainers.image.created={{.Date}}"
|
||||||
|
- "--label=org.opencontainers.image.title={{.ProjectName}}"
|
||||||
|
- "--label=org.opencontainers.image.version={{.Version}}"
|
||||||
|
- "--label=org.opencontainers.image.revision={{.FullCommit}}"
|
||||||
|
- "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}"
|
||||||
|
- "--label=maintainer=dev@netbird.io"
|
||||||
|
- image_templates:
|
||||||
|
- netbirdio/netbird-server:{{ .Version }}-arm64v8
|
||||||
|
- ghcr.io/netbirdio/netbird-server:{{ .Version }}-arm64v8
|
||||||
|
ids:
|
||||||
|
- netbird-server
|
||||||
|
goarch: arm64
|
||||||
|
use: buildx
|
||||||
|
dockerfile: combined/Dockerfile
|
||||||
|
build_flag_templates:
|
||||||
|
- "--platform=linux/arm64"
|
||||||
|
- "--label=org.opencontainers.image.created={{.Date}}"
|
||||||
|
- "--label=org.opencontainers.image.title={{.ProjectName}}"
|
||||||
|
- "--label=org.opencontainers.image.version={{.Version}}"
|
||||||
|
- "--label=org.opencontainers.image.revision={{.FullCommit}}"
|
||||||
|
- "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}"
|
||||||
|
- "--label=maintainer=dev@netbird.io"
|
||||||
|
- image_templates:
|
||||||
|
- netbirdio/netbird-server:{{ .Version }}-arm
|
||||||
|
- ghcr.io/netbirdio/netbird-server:{{ .Version }}-arm
|
||||||
|
ids:
|
||||||
|
- netbird-server
|
||||||
|
goarch: arm
|
||||||
|
goarm: 6
|
||||||
|
use: buildx
|
||||||
|
dockerfile: combined/Dockerfile
|
||||||
|
build_flag_templates:
|
||||||
|
- "--platform=linux/arm"
|
||||||
|
- "--label=org.opencontainers.image.created={{.Date}}"
|
||||||
|
- "--label=org.opencontainers.image.title={{.ProjectName}}"
|
||||||
|
- "--label=org.opencontainers.image.version={{.Version}}"
|
||||||
|
- "--label=org.opencontainers.image.revision={{.FullCommit}}"
|
||||||
|
- "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}"
|
||||||
|
- "--label=maintainer=dev@netbird.io"
|
||||||
|
- image_templates:
|
||||||
|
- netbirdio/reverse-proxy:{{ .Version }}-amd64
|
||||||
|
- ghcr.io/netbirdio/reverse-proxy:{{ .Version }}-amd64
|
||||||
|
ids:
|
||||||
|
- netbird-proxy
|
||||||
|
goarch: amd64
|
||||||
|
use: buildx
|
||||||
|
dockerfile: proxy/Dockerfile
|
||||||
|
build_flag_templates:
|
||||||
|
- "--platform=linux/amd64"
|
||||||
|
- "--label=org.opencontainers.image.created={{.Date}}"
|
||||||
|
- "--label=org.opencontainers.image.title={{.ProjectName}}"
|
||||||
|
- "--label=org.opencontainers.image.version={{.Version}}"
|
||||||
|
- "--label=org.opencontainers.image.revision={{.FullCommit}}"
|
||||||
|
- "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}"
|
||||||
|
- "--label=maintainer=dev@netbird.io"
|
||||||
|
- image_templates:
|
||||||
|
- netbirdio/reverse-proxy:{{ .Version }}-arm64v8
|
||||||
|
- ghcr.io/netbirdio/reverse-proxy:{{ .Version }}-arm64v8
|
||||||
|
ids:
|
||||||
|
- netbird-proxy
|
||||||
|
goarch: arm64
|
||||||
|
use: buildx
|
||||||
|
dockerfile: proxy/Dockerfile
|
||||||
|
build_flag_templates:
|
||||||
|
- "--platform=linux/arm64"
|
||||||
|
- "--label=org.opencontainers.image.created={{.Date}}"
|
||||||
|
- "--label=org.opencontainers.image.title={{.ProjectName}}"
|
||||||
|
- "--label=org.opencontainers.image.version={{.Version}}"
|
||||||
|
- "--label=org.opencontainers.image.revision={{.FullCommit}}"
|
||||||
|
- "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}"
|
||||||
|
- "--label=maintainer=dev@netbird.io"
|
||||||
|
- image_templates:
|
||||||
|
- netbirdio/reverse-proxy:{{ .Version }}-arm
|
||||||
|
- ghcr.io/netbirdio/reverse-proxy:{{ .Version }}-arm
|
||||||
|
ids:
|
||||||
|
- netbird-proxy
|
||||||
|
goarch: arm
|
||||||
|
goarm: 6
|
||||||
|
use: buildx
|
||||||
|
dockerfile: proxy/Dockerfile
|
||||||
|
build_flag_templates:
|
||||||
|
- "--platform=linux/arm"
|
||||||
|
- "--label=org.opencontainers.image.created={{.Date}}"
|
||||||
|
- "--label=org.opencontainers.image.title={{.ProjectName}}"
|
||||||
|
- "--label=org.opencontainers.image.version={{.Version}}"
|
||||||
|
- "--label=org.opencontainers.image.revision={{.FullCommit}}"
|
||||||
|
- "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}"
|
||||||
|
- "--label=maintainer=dev@netbird.io"
|
||||||
docker_manifests:
|
docker_manifests:
|
||||||
- name_template: netbirdio/netbird:{{ .Version }}
|
- name_template: netbirdio/netbird:{{ .Version }}
|
||||||
image_templates:
|
image_templates:
|
||||||
@@ -598,6 +757,18 @@ docker_manifests:
|
|||||||
- netbirdio/upload:{{ .Version }}-arm
|
- netbirdio/upload:{{ .Version }}-arm
|
||||||
- netbirdio/upload:{{ .Version }}-amd64
|
- netbirdio/upload:{{ .Version }}-amd64
|
||||||
|
|
||||||
|
- name_template: netbirdio/netbird-server:{{ .Version }}
|
||||||
|
image_templates:
|
||||||
|
- netbirdio/netbird-server:{{ .Version }}-arm64v8
|
||||||
|
- netbirdio/netbird-server:{{ .Version }}-arm
|
||||||
|
- netbirdio/netbird-server:{{ .Version }}-amd64
|
||||||
|
|
||||||
|
- name_template: netbirdio/netbird-server:latest
|
||||||
|
image_templates:
|
||||||
|
- netbirdio/netbird-server:{{ .Version }}-arm64v8
|
||||||
|
- netbirdio/netbird-server:{{ .Version }}-arm
|
||||||
|
- netbirdio/netbird-server:{{ .Version }}-amd64
|
||||||
|
|
||||||
- name_template: ghcr.io/netbirdio/netbird:{{ .Version }}
|
- name_template: ghcr.io/netbirdio/netbird:{{ .Version }}
|
||||||
image_templates:
|
image_templates:
|
||||||
- ghcr.io/netbirdio/netbird:{{ .Version }}-arm64v8
|
- ghcr.io/netbirdio/netbird:{{ .Version }}-arm64v8
|
||||||
@@ -675,6 +846,43 @@ docker_manifests:
|
|||||||
- ghcr.io/netbirdio/upload:{{ .Version }}-arm64v8
|
- ghcr.io/netbirdio/upload:{{ .Version }}-arm64v8
|
||||||
- ghcr.io/netbirdio/upload:{{ .Version }}-arm
|
- ghcr.io/netbirdio/upload:{{ .Version }}-arm
|
||||||
- ghcr.io/netbirdio/upload:{{ .Version }}-amd64
|
- ghcr.io/netbirdio/upload:{{ .Version }}-amd64
|
||||||
|
|
||||||
|
- name_template: ghcr.io/netbirdio/netbird-server:{{ .Version }}
|
||||||
|
image_templates:
|
||||||
|
- ghcr.io/netbirdio/netbird-server:{{ .Version }}-arm64v8
|
||||||
|
- ghcr.io/netbirdio/netbird-server:{{ .Version }}-arm
|
||||||
|
- ghcr.io/netbirdio/netbird-server:{{ .Version }}-amd64
|
||||||
|
|
||||||
|
- name_template: ghcr.io/netbirdio/netbird-server:latest
|
||||||
|
image_templates:
|
||||||
|
- ghcr.io/netbirdio/netbird-server:{{ .Version }}-arm64v8
|
||||||
|
- ghcr.io/netbirdio/netbird-server:{{ .Version }}-arm
|
||||||
|
- ghcr.io/netbirdio/netbird-server:{{ .Version }}-amd64
|
||||||
|
|
||||||
|
- name_template: netbirdio/reverse-proxy:{{ .Version }}
|
||||||
|
image_templates:
|
||||||
|
- netbirdio/reverse-proxy:{{ .Version }}-arm64v8
|
||||||
|
- netbirdio/reverse-proxy:{{ .Version }}-arm
|
||||||
|
- netbirdio/reverse-proxy:{{ .Version }}-amd64
|
||||||
|
|
||||||
|
- name_template: netbirdio/reverse-proxy:latest
|
||||||
|
image_templates:
|
||||||
|
- netbirdio/reverse-proxy:{{ .Version }}-arm64v8
|
||||||
|
- netbirdio/reverse-proxy:{{ .Version }}-arm
|
||||||
|
- netbirdio/reverse-proxy:{{ .Version }}-amd64
|
||||||
|
|
||||||
|
- name_template: ghcr.io/netbirdio/reverse-proxy:{{ .Version }}
|
||||||
|
image_templates:
|
||||||
|
- ghcr.io/netbirdio/reverse-proxy:{{ .Version }}-arm64v8
|
||||||
|
- ghcr.io/netbirdio/reverse-proxy:{{ .Version }}-arm
|
||||||
|
- ghcr.io/netbirdio/reverse-proxy:{{ .Version }}-amd64
|
||||||
|
|
||||||
|
- name_template: ghcr.io/netbirdio/reverse-proxy:latest
|
||||||
|
image_templates:
|
||||||
|
- ghcr.io/netbirdio/reverse-proxy:{{ .Version }}-arm64v8
|
||||||
|
- ghcr.io/netbirdio/reverse-proxy:{{ .Version }}-arm
|
||||||
|
- ghcr.io/netbirdio/reverse-proxy:{{ .Version }}-amd64
|
||||||
|
|
||||||
brews:
|
brews:
|
||||||
- ids:
|
- ids:
|
||||||
- default
|
- default
|
||||||
@@ -695,7 +903,7 @@ brews:
|
|||||||
uploads:
|
uploads:
|
||||||
- name: debian
|
- name: debian
|
||||||
ids:
|
ids:
|
||||||
- netbird-deb
|
- netbird_deb
|
||||||
mode: archive
|
mode: archive
|
||||||
target: https://pkgs.wiretrustee.com/debian/pool/{{ .ArtifactName }};deb.distribution=stable;deb.component=main;deb.architecture={{ if .Arm }}armhf{{ else }}{{ .Arch }}{{ end }};deb.package=
|
target: https://pkgs.wiretrustee.com/debian/pool/{{ .ArtifactName }};deb.distribution=stable;deb.component=main;deb.architecture={{ if .Arm }}armhf{{ else }}{{ .Arch }}{{ end }};deb.package=
|
||||||
username: dev@wiretrustee.com
|
username: dev@wiretrustee.com
|
||||||
@@ -703,7 +911,7 @@ uploads:
|
|||||||
|
|
||||||
- name: yum
|
- name: yum
|
||||||
ids:
|
ids:
|
||||||
- netbird-rpm
|
- netbird_rpm
|
||||||
mode: archive
|
mode: archive
|
||||||
target: https://pkgs.wiretrustee.com/yum/{{ .Arch }}{{ if .Arm }}{{ .Arm }}{{ end }}
|
target: https://pkgs.wiretrustee.com/yum/{{ .Arch }}{{ if .Arm }}{{ .Arm }}{{ end }}
|
||||||
username: dev@wiretrustee.com
|
username: dev@wiretrustee.com
|
||||||
@@ -713,8 +921,10 @@ checksum:
|
|||||||
extra_files:
|
extra_files:
|
||||||
- glob: ./infrastructure_files/getting-started-with-zitadel.sh
|
- glob: ./infrastructure_files/getting-started-with-zitadel.sh
|
||||||
- glob: ./release_files/install.sh
|
- glob: ./release_files/install.sh
|
||||||
|
- glob: ./infrastructure_files/getting-started.sh
|
||||||
|
|
||||||
release:
|
release:
|
||||||
extra_files:
|
extra_files:
|
||||||
- glob: ./infrastructure_files/getting-started-with-zitadel.sh
|
- glob: ./infrastructure_files/getting-started-with-zitadel.sh
|
||||||
- glob: ./release_files/install.sh
|
- glob: ./release_files/install.sh
|
||||||
|
- glob: ./infrastructure_files/getting-started.sh
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ nfpms:
|
|||||||
- maintainer: Netbird <dev@netbird.io>
|
- maintainer: Netbird <dev@netbird.io>
|
||||||
description: Netbird client UI.
|
description: Netbird client UI.
|
||||||
homepage: https://netbird.io/
|
homepage: https://netbird.io/
|
||||||
id: netbird-ui-deb
|
id: netbird_ui_deb
|
||||||
package_name: netbird-ui
|
package_name: netbird-ui
|
||||||
builds:
|
builds:
|
||||||
- netbird-ui
|
- netbird-ui
|
||||||
@@ -80,7 +80,7 @@ nfpms:
|
|||||||
- maintainer: Netbird <dev@netbird.io>
|
- maintainer: Netbird <dev@netbird.io>
|
||||||
description: Netbird client UI.
|
description: Netbird client UI.
|
||||||
homepage: https://netbird.io/
|
homepage: https://netbird.io/
|
||||||
id: netbird-ui-rpm
|
id: netbird_ui_rpm
|
||||||
package_name: netbird-ui
|
package_name: netbird-ui
|
||||||
builds:
|
builds:
|
||||||
- netbird-ui
|
- netbird-ui
|
||||||
@@ -95,11 +95,14 @@ nfpms:
|
|||||||
dst: /usr/share/pixmaps/netbird.png
|
dst: /usr/share/pixmaps/netbird.png
|
||||||
dependencies:
|
dependencies:
|
||||||
- netbird
|
- netbird
|
||||||
|
rpm:
|
||||||
|
signature:
|
||||||
|
key_file: '{{ if index .Env "GPG_RPM_KEY_FILE" }}{{ .Env.GPG_RPM_KEY_FILE }}{{ end }}'
|
||||||
|
|
||||||
uploads:
|
uploads:
|
||||||
- name: debian
|
- name: debian
|
||||||
ids:
|
ids:
|
||||||
- netbird-ui-deb
|
- netbird_ui_deb
|
||||||
mode: archive
|
mode: archive
|
||||||
target: https://pkgs.wiretrustee.com/debian/pool/{{ .ArtifactName }};deb.distribution=stable;deb.component=main;deb.architecture={{ if .Arm }}armhf{{ else }}{{ .Arch }}{{ end }};deb.package=
|
target: https://pkgs.wiretrustee.com/debian/pool/{{ .ArtifactName }};deb.distribution=stable;deb.component=main;deb.architecture={{ if .Arm }}armhf{{ else }}{{ .Arch }}{{ end }};deb.package=
|
||||||
username: dev@wiretrustee.com
|
username: dev@wiretrustee.com
|
||||||
@@ -107,7 +110,7 @@ uploads:
|
|||||||
|
|
||||||
- name: yum
|
- name: yum
|
||||||
ids:
|
ids:
|
||||||
- netbird-ui-rpm
|
- netbird_ui_rpm
|
||||||
mode: archive
|
mode: archive
|
||||||
target: https://pkgs.wiretrustee.com/yum/{{ .Arch }}{{ if .Arm }}{{ .Arm }}{{ end }}
|
target: https://pkgs.wiretrustee.com/yum/{{ .Arch }}{{ if .Arm }}{{ .Arm }}{{ end }}
|
||||||
username: dev@wiretrustee.com
|
username: dev@wiretrustee.com
|
||||||
|
|||||||
@@ -136,6 +136,14 @@ checked out and set up:
|
|||||||
go mod tidy
|
go mod tidy
|
||||||
```
|
```
|
||||||
|
|
||||||
|
6. Configure Git hooks for automatic linting:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make setup-hooks
|
||||||
|
```
|
||||||
|
|
||||||
|
This will configure Git to run linting automatically before each push, helping catch issues early.
|
||||||
|
|
||||||
### Dev Container Support
|
### Dev Container Support
|
||||||
|
|
||||||
If you prefer using a dev container for development, NetBird now includes support for dev containers.
|
If you prefer using a dev container for development, NetBird now includes support for dev containers.
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
## Contributor License Agreement
|
## Contributor License Agreement
|
||||||
|
|
||||||
This Contributor License Agreement (referred to as the "Agreement") is entered into by the individual
|
This Contributor License Agreement (referred to as the "Agreement") is entered into by the individual
|
||||||
submitting this Agreement and NetBird GmbH, c/o Max-Beer-Straße 2-4 Münzstraße 12 10178 Berlin, Germany,
|
submitting this Agreement and NetBird GmbH, Brunnenstraße 196, 10119 Berlin, Germany,
|
||||||
referred to as "NetBird" (collectively, the "Parties"). The Agreement outlines the terms and conditions
|
referred to as "NetBird" (collectively, the "Parties"). The Agreement outlines the terms and conditions
|
||||||
under which NetBird may utilize software contributions provided by the Contributor for inclusion in
|
under which NetBird may utilize software contributions provided by the Contributor for inclusion in
|
||||||
its software development projects. By submitting this Agreement, the Contributor confirms their acceptance
|
its software development projects. By submitting this Agreement, the Contributor confirms their acceptance
|
||||||
|
|||||||
2
LICENSE
2
LICENSE
@@ -1,4 +1,4 @@
|
|||||||
This BSD‑3‑Clause license applies to all parts of the repository except for the directories management/, signal/ and relay/.
|
This BSD‑3‑Clause license applies to all parts of the repository except for the directories management/, signal/, relay/ and combined/.
|
||||||
Those directories are licensed under the GNU Affero General Public License version 3.0 (AGPLv3). See the respective LICENSE files inside each directory.
|
Those directories are licensed under the GNU Affero General Public License version 3.0 (AGPLv3). See the respective LICENSE files inside each directory.
|
||||||
|
|
||||||
BSD 3-Clause License
|
BSD 3-Clause License
|
||||||
|
|||||||
27
Makefile
Normal file
27
Makefile
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
.PHONY: lint lint-all lint-install setup-hooks
|
||||||
|
GOLANGCI_LINT := $(shell pwd)/bin/golangci-lint
|
||||||
|
|
||||||
|
# Install golangci-lint locally if needed
|
||||||
|
$(GOLANGCI_LINT):
|
||||||
|
@echo "Installing golangci-lint..."
|
||||||
|
@mkdir -p ./bin
|
||||||
|
@GOBIN=$(shell pwd)/bin go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@latest
|
||||||
|
|
||||||
|
# Lint only changed files (fast, for pre-push)
|
||||||
|
lint: $(GOLANGCI_LINT)
|
||||||
|
@echo "Running lint on changed files..."
|
||||||
|
@$(GOLANGCI_LINT) run --new-from-rev=origin/main --timeout=2m
|
||||||
|
|
||||||
|
# Lint entire codebase (slow, matches CI)
|
||||||
|
lint-all: $(GOLANGCI_LINT)
|
||||||
|
@echo "Running lint on all files..."
|
||||||
|
@$(GOLANGCI_LINT) run --timeout=12m
|
||||||
|
|
||||||
|
# Just install the linter
|
||||||
|
lint-install: $(GOLANGCI_LINT)
|
||||||
|
|
||||||
|
# Setup git hooks for all developers
|
||||||
|
setup-hooks:
|
||||||
|
@git config core.hooksPath .githooks
|
||||||
|
@chmod +x .githooks/pre-push
|
||||||
|
@echo "✅ Git hooks configured! Pre-push will now run 'make lint'"
|
||||||
16
README.md
16
README.md
@@ -38,6 +38,11 @@
|
|||||||
|
|
||||||
</strong>
|
</strong>
|
||||||
<br>
|
<br>
|
||||||
|
<strong>
|
||||||
|
🚀 <a href="https://careers.netbird.io">We are hiring! Join us at careers.netbird.io</a>
|
||||||
|
</strong>
|
||||||
|
<br>
|
||||||
|
<br>
|
||||||
<a href="https://registry.terraform.io/providers/netbirdio/netbird/latest">
|
<a href="https://registry.terraform.io/providers/netbirdio/netbird/latest">
|
||||||
New: NetBird terraform provider
|
New: NetBird terraform provider
|
||||||
</a>
|
</a>
|
||||||
@@ -55,8 +60,8 @@
|
|||||||
|
|
||||||
https://github.com/user-attachments/assets/10cec749-bb56-4ab3-97af-4e38850108d2
|
https://github.com/user-attachments/assets/10cec749-bb56-4ab3-97af-4e38850108d2
|
||||||
|
|
||||||
### NetBird on Lawrence Systems (Video)
|
### Self-Host NetBird (Video)
|
||||||
[](https://www.youtube.com/watch?v=Kwrff6h0rEw)
|
[](https://youtu.be/bZAgpT6nzaQ)
|
||||||
|
|
||||||
### Key features
|
### Key features
|
||||||
|
|
||||||
@@ -85,7 +90,7 @@ Follow the [Advanced guide with a custom identity provider](https://docs.netbird
|
|||||||
|
|
||||||
**Infrastructure requirements:**
|
**Infrastructure requirements:**
|
||||||
- A Linux VM with at least **1CPU** and **2GB** of memory.
|
- A Linux VM with at least **1CPU** and **2GB** of memory.
|
||||||
- The VM should be publicly accessible on TCP ports **80** and **443** and UDP ports: **3478**, **49152-65535**.
|
- The VM should be publicly accessible on TCP ports **80** and **443** and UDP port: **3478**.
|
||||||
- **Public domain** name pointing to the VM.
|
- **Public domain** name pointing to the VM.
|
||||||
|
|
||||||
**Software requirements:**
|
**Software requirements:**
|
||||||
@@ -98,7 +103,7 @@ Follow the [Advanced guide with a custom identity provider](https://docs.netbird
|
|||||||
**Steps**
|
**Steps**
|
||||||
- Download and run the installation script:
|
- Download and run the installation script:
|
||||||
```bash
|
```bash
|
||||||
export NETBIRD_DOMAIN=netbird.example.com; curl -fsSL https://github.com/netbirdio/netbird/releases/latest/download/getting-started-with-zitadel.sh | bash
|
export NETBIRD_DOMAIN=netbird.example.com; curl -fsSL https://github.com/netbirdio/netbird/releases/latest/download/getting-started.sh | bash
|
||||||
```
|
```
|
||||||
- Once finished, you can manage the resources via `docker-compose`
|
- Once finished, you can manage the resources via `docker-compose`
|
||||||
|
|
||||||
@@ -113,7 +118,7 @@ export NETBIRD_DOMAIN=netbird.example.com; curl -fsSL https://github.com/netbird
|
|||||||
[Coturn](https://github.com/coturn/coturn) is the one that has been successfully used for STUN and TURN in NetBird setups.
|
[Coturn](https://github.com/coturn/coturn) is the one that has been successfully used for STUN and TURN in NetBird setups.
|
||||||
|
|
||||||
<p float="left" align="middle">
|
<p float="left" align="middle">
|
||||||
<img src="https://docs.netbird.io/docs-static/img/architecture/high-level-dia.png" width="700"/>
|
<img src="https://docs.netbird.io/docs-static/img/about-netbird/high-level-dia.png" width="700"/>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
See a complete [architecture overview](https://docs.netbird.io/about-netbird/how-netbird-works#architecture) for details.
|
See a complete [architecture overview](https://docs.netbird.io/about-netbird/how-netbird-works#architecture) for details.
|
||||||
@@ -121,6 +126,7 @@ See a complete [architecture overview](https://docs.netbird.io/about-netbird/how
|
|||||||
### Community projects
|
### Community projects
|
||||||
- [NetBird installer script](https://github.com/physk/netbird-installer)
|
- [NetBird installer script](https://github.com/physk/netbird-installer)
|
||||||
- [NetBird ansible collection by Dominion Solutions](https://galaxy.ansible.com/ui/repo/published/dominion_solutions/netbird/)
|
- [NetBird ansible collection by Dominion Solutions](https://galaxy.ansible.com/ui/repo/published/dominion_solutions/netbird/)
|
||||||
|
- [netbird-tui](https://github.com/n0pashkov/netbird-tui) — terminal UI for managing NetBird peers, routes, and settings
|
||||||
|
|
||||||
**Note**: The `main` branch may be in an *unstable or even broken state* during development.
|
**Note**: The `main` branch may be in an *unstable or even broken state* during development.
|
||||||
For stable versions, see [releases](https://github.com/netbirdio/netbird/releases).
|
For stable versions, see [releases](https://github.com/netbirdio/netbird/releases).
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
# sudo podman build -t localhost/netbird:latest -f client/Dockerfile --ignorefile .dockerignore-client .
|
# sudo podman build -t localhost/netbird:latest -f client/Dockerfile --ignorefile .dockerignore-client .
|
||||||
# sudo podman run --rm -it --cap-add={BPF,NET_ADMIN,NET_RAW} localhost/netbird:latest
|
# sudo podman run --rm -it --cap-add={BPF,NET_ADMIN,NET_RAW} localhost/netbird:latest
|
||||||
|
|
||||||
FROM alpine:3.22.2
|
FROM alpine:3.23.3
|
||||||
# iproute2: busybox doesn't display ip rules properly
|
# iproute2: busybox doesn't display ip rules properly
|
||||||
RUN apk add --no-cache \
|
RUN apk add --no-cache \
|
||||||
bash \
|
bash \
|
||||||
@@ -17,8 +17,8 @@ ENV \
|
|||||||
NETBIRD_BIN="/usr/local/bin/netbird" \
|
NETBIRD_BIN="/usr/local/bin/netbird" \
|
||||||
NB_LOG_FILE="console,/var/log/netbird/client.log" \
|
NB_LOG_FILE="console,/var/log/netbird/client.log" \
|
||||||
NB_DAEMON_ADDR="unix:///var/run/netbird.sock" \
|
NB_DAEMON_ADDR="unix:///var/run/netbird.sock" \
|
||||||
NB_ENTRYPOINT_SERVICE_TIMEOUT="5" \
|
NB_ENABLE_CAPTURE="false" \
|
||||||
NB_ENTRYPOINT_LOGIN_TIMEOUT="5"
|
NB_ENTRYPOINT_SERVICE_TIMEOUT="30"
|
||||||
|
|
||||||
ENTRYPOINT [ "/usr/local/bin/netbird-entrypoint.sh" ]
|
ENTRYPOINT [ "/usr/local/bin/netbird-entrypoint.sh" ]
|
||||||
|
|
||||||
|
|||||||
@@ -23,8 +23,8 @@ ENV \
|
|||||||
NB_DAEMON_ADDR="unix:///var/lib/netbird/netbird.sock" \
|
NB_DAEMON_ADDR="unix:///var/lib/netbird/netbird.sock" \
|
||||||
NB_LOG_FILE="console,/var/lib/netbird/client.log" \
|
NB_LOG_FILE="console,/var/lib/netbird/client.log" \
|
||||||
NB_DISABLE_DNS="true" \
|
NB_DISABLE_DNS="true" \
|
||||||
NB_ENTRYPOINT_SERVICE_TIMEOUT="5" \
|
NB_ENABLE_CAPTURE="false" \
|
||||||
NB_ENTRYPOINT_LOGIN_TIMEOUT="1"
|
NB_ENTRYPOINT_SERVICE_TIMEOUT="30"
|
||||||
|
|
||||||
ENTRYPOINT [ "/usr/local/bin/netbird-entrypoint.sh" ]
|
ENTRYPOINT [ "/usr/local/bin/netbird-entrypoint.sh" ]
|
||||||
|
|
||||||
|
|||||||
@@ -4,22 +4,31 @@ package android
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"slices"
|
"slices"
|
||||||
"sync"
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"golang.org/x/exp/maps"
|
||||||
|
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
|
|
||||||
"github.com/netbirdio/netbird/client/iface/device"
|
"github.com/netbirdio/netbird/client/iface/device"
|
||||||
"github.com/netbirdio/netbird/client/internal"
|
"github.com/netbirdio/netbird/client/internal"
|
||||||
|
"github.com/netbirdio/netbird/client/internal/debug"
|
||||||
"github.com/netbirdio/netbird/client/internal/dns"
|
"github.com/netbirdio/netbird/client/internal/dns"
|
||||||
"github.com/netbirdio/netbird/client/internal/listener"
|
"github.com/netbirdio/netbird/client/internal/listener"
|
||||||
"github.com/netbirdio/netbird/client/internal/peer"
|
"github.com/netbirdio/netbird/client/internal/peer"
|
||||||
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
||||||
|
"github.com/netbirdio/netbird/client/internal/routemanager"
|
||||||
"github.com/netbirdio/netbird/client/internal/stdnet"
|
"github.com/netbirdio/netbird/client/internal/stdnet"
|
||||||
"github.com/netbirdio/netbird/client/net"
|
"github.com/netbirdio/netbird/client/net"
|
||||||
"github.com/netbirdio/netbird/client/system"
|
"github.com/netbirdio/netbird/client/system"
|
||||||
"github.com/netbirdio/netbird/formatter"
|
"github.com/netbirdio/netbird/formatter"
|
||||||
|
"github.com/netbirdio/netbird/route"
|
||||||
|
"github.com/netbirdio/netbird/shared/management/domain"
|
||||||
|
types "github.com/netbirdio/netbird/upload-server/types"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ConnectionListener export internal Listener for mobile
|
// ConnectionListener export internal Listener for mobile
|
||||||
@@ -53,7 +62,6 @@ func init() {
|
|||||||
|
|
||||||
// Client struct manage the life circle of background service
|
// Client struct manage the life circle of background service
|
||||||
type Client struct {
|
type Client struct {
|
||||||
cfgFile string
|
|
||||||
tunAdapter device.TunAdapter
|
tunAdapter device.TunAdapter
|
||||||
iFaceDiscover IFaceDiscover
|
iFaceDiscover IFaceDiscover
|
||||||
recorder *peer.Status
|
recorder *peer.Status
|
||||||
@@ -63,16 +71,38 @@ type Client struct {
|
|||||||
uiVersion string
|
uiVersion string
|
||||||
networkChangeListener listener.NetworkChangeListener
|
networkChangeListener listener.NetworkChangeListener
|
||||||
|
|
||||||
|
stateMu sync.RWMutex
|
||||||
connectClient *internal.ConnectClient
|
connectClient *internal.ConnectClient
|
||||||
|
config *profilemanager.Config
|
||||||
|
cacheDir string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) setState(cfg *profilemanager.Config, cacheDir string, cc *internal.ConnectClient) {
|
||||||
|
c.stateMu.Lock()
|
||||||
|
defer c.stateMu.Unlock()
|
||||||
|
c.config = cfg
|
||||||
|
c.cacheDir = cacheDir
|
||||||
|
c.connectClient = cc
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) stateSnapshot() (*profilemanager.Config, string, *internal.ConnectClient) {
|
||||||
|
c.stateMu.RLock()
|
||||||
|
defer c.stateMu.RUnlock()
|
||||||
|
return c.config, c.cacheDir, c.connectClient
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) getConnectClient() *internal.ConnectClient {
|
||||||
|
c.stateMu.RLock()
|
||||||
|
defer c.stateMu.RUnlock()
|
||||||
|
return c.connectClient
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewClient instantiate a new Client
|
// NewClient instantiate a new Client
|
||||||
func NewClient(cfgFile string, androidSDKVersion int, deviceName string, uiVersion string, tunAdapter TunAdapter, iFaceDiscover IFaceDiscover, networkChangeListener NetworkChangeListener) *Client {
|
func NewClient(androidSDKVersion int, deviceName string, uiVersion string, tunAdapter TunAdapter, iFaceDiscover IFaceDiscover, networkChangeListener NetworkChangeListener) *Client {
|
||||||
execWorkaround(androidSDKVersion)
|
execWorkaround(androidSDKVersion)
|
||||||
|
|
||||||
net.SetAndroidProtectSocketFn(tunAdapter.ProtectSocket)
|
net.SetAndroidProtectSocketFn(tunAdapter.ProtectSocket)
|
||||||
return &Client{
|
return &Client{
|
||||||
cfgFile: cfgFile,
|
|
||||||
deviceName: deviceName,
|
deviceName: deviceName,
|
||||||
uiVersion: uiVersion,
|
uiVersion: uiVersion,
|
||||||
tunAdapter: tunAdapter,
|
tunAdapter: tunAdapter,
|
||||||
@@ -84,10 +114,17 @@ func NewClient(cfgFile string, androidSDKVersion int, deviceName string, uiVersi
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Run start the internal client. It is a blocker function
|
// Run start the internal client. It is a blocker function
|
||||||
func (c *Client) Run(urlOpener URLOpener, dns *DNSList, dnsReadyListener DnsReadyListener, envList *EnvList) error {
|
func (c *Client) Run(platformFiles PlatformFiles, urlOpener URLOpener, isAndroidTV bool, dns *DNSList, dnsReadyListener DnsReadyListener, envList *EnvList) error {
|
||||||
exportEnvList(envList)
|
exportEnvList(envList)
|
||||||
|
|
||||||
|
cfgFile := platformFiles.ConfigurationFilePath()
|
||||||
|
stateFile := platformFiles.StateFilePath()
|
||||||
|
cacheDir := platformFiles.CacheDir()
|
||||||
|
|
||||||
|
log.Infof("Starting client with config: %s, state: %s", cfgFile, stateFile)
|
||||||
|
|
||||||
cfg, err := profilemanager.UpdateOrCreateConfig(profilemanager.ConfigInput{
|
cfg, err := profilemanager.UpdateOrCreateConfig(profilemanager.ConfigInput{
|
||||||
ConfigPath: c.cfgFile,
|
ConfigPath: cfgFile,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -107,23 +144,31 @@ func (c *Client) Run(urlOpener URLOpener, dns *DNSList, dnsReadyListener DnsRead
|
|||||||
c.ctxCancelLock.Unlock()
|
c.ctxCancelLock.Unlock()
|
||||||
|
|
||||||
auth := NewAuthWithConfig(ctx, cfg)
|
auth := NewAuthWithConfig(ctx, cfg)
|
||||||
err = auth.login(urlOpener)
|
err = auth.login(urlOpener, isAndroidTV)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// todo do not throw error in case of cancelled context
|
// todo do not throw error in case of cancelled context
|
||||||
ctx = internal.CtxInitState(ctx)
|
ctx = internal.CtxInitState(ctx)
|
||||||
c.connectClient = internal.NewConnectClient(ctx, cfg, c.recorder)
|
connectClient := internal.NewConnectClient(ctx, cfg, c.recorder)
|
||||||
return c.connectClient.RunOnAndroid(c.tunAdapter, c.iFaceDiscover, c.networkChangeListener, slices.Clone(dns.items), dnsReadyListener)
|
c.setState(cfg, cacheDir, connectClient)
|
||||||
|
return connectClient.RunOnAndroid(c.tunAdapter, c.iFaceDiscover, c.networkChangeListener, slices.Clone(dns.items), dnsReadyListener, stateFile, cacheDir)
|
||||||
}
|
}
|
||||||
|
|
||||||
// RunWithoutLogin we apply this type of run function when the backed has been started without UI (i.e. after reboot).
|
// RunWithoutLogin we apply this type of run function when the backed has been started without UI (i.e. after reboot).
|
||||||
// In this case make no sense handle registration steps.
|
// In this case make no sense handle registration steps.
|
||||||
func (c *Client) RunWithoutLogin(dns *DNSList, dnsReadyListener DnsReadyListener, envList *EnvList) error {
|
func (c *Client) RunWithoutLogin(platformFiles PlatformFiles, dns *DNSList, dnsReadyListener DnsReadyListener, envList *EnvList) error {
|
||||||
exportEnvList(envList)
|
exportEnvList(envList)
|
||||||
|
|
||||||
|
cfgFile := platformFiles.ConfigurationFilePath()
|
||||||
|
stateFile := platformFiles.StateFilePath()
|
||||||
|
cacheDir := platformFiles.CacheDir()
|
||||||
|
|
||||||
|
log.Infof("Starting client without login with config: %s, state: %s", cfgFile, stateFile)
|
||||||
|
|
||||||
cfg, err := profilemanager.UpdateOrCreateConfig(profilemanager.ConfigInput{
|
cfg, err := profilemanager.UpdateOrCreateConfig(profilemanager.ConfigInput{
|
||||||
ConfigPath: c.cfgFile,
|
ConfigPath: cfgFile,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -141,8 +186,9 @@ func (c *Client) RunWithoutLogin(dns *DNSList, dnsReadyListener DnsReadyListener
|
|||||||
|
|
||||||
// todo do not throw error in case of cancelled context
|
// todo do not throw error in case of cancelled context
|
||||||
ctx = internal.CtxInitState(ctx)
|
ctx = internal.CtxInitState(ctx)
|
||||||
c.connectClient = internal.NewConnectClient(ctx, cfg, c.recorder)
|
connectClient := internal.NewConnectClient(ctx, cfg, c.recorder)
|
||||||
return c.connectClient.RunOnAndroid(c.tunAdapter, c.iFaceDiscover, c.networkChangeListener, slices.Clone(dns.items), dnsReadyListener)
|
c.setState(cfg, cacheDir, connectClient)
|
||||||
|
return connectClient.RunOnAndroid(c.tunAdapter, c.iFaceDiscover, c.networkChangeListener, slices.Clone(dns.items), dnsReadyListener, stateFile, cacheDir)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stop the internal client and free the resources
|
// Stop the internal client and free the resources
|
||||||
@@ -156,6 +202,87 @@ func (c *Client) Stop() {
|
|||||||
c.ctxCancel()
|
c.ctxCancel()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *Client) RenewTun(fd int) error {
|
||||||
|
cc := c.getConnectClient()
|
||||||
|
if cc == nil {
|
||||||
|
return fmt.Errorf("engine not running")
|
||||||
|
}
|
||||||
|
|
||||||
|
e := cc.Engine()
|
||||||
|
if e == nil {
|
||||||
|
return fmt.Errorf("engine not initialized")
|
||||||
|
}
|
||||||
|
|
||||||
|
return e.RenewTun(fd)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DebugBundle generates a debug bundle, uploads it, and returns the upload key.
|
||||||
|
// It works both with and without a running engine.
|
||||||
|
func (c *Client) DebugBundle(platformFiles PlatformFiles, anonymize bool) (string, error) {
|
||||||
|
cfg, cacheDir, cc := c.stateSnapshot()
|
||||||
|
|
||||||
|
// If the engine hasn't been started, load config from disk
|
||||||
|
if cfg == nil {
|
||||||
|
var err error
|
||||||
|
cfg, err = profilemanager.UpdateOrCreateConfig(profilemanager.ConfigInput{
|
||||||
|
ConfigPath: platformFiles.ConfigurationFilePath(),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("load config: %w", err)
|
||||||
|
}
|
||||||
|
cacheDir = platformFiles.CacheDir()
|
||||||
|
}
|
||||||
|
|
||||||
|
deps := debug.GeneratorDependencies{
|
||||||
|
InternalConfig: cfg,
|
||||||
|
StatusRecorder: c.recorder,
|
||||||
|
TempDir: cacheDir,
|
||||||
|
}
|
||||||
|
|
||||||
|
if cc != nil {
|
||||||
|
resp, err := cc.GetLatestSyncResponse()
|
||||||
|
if err != nil {
|
||||||
|
log.Warnf("get latest sync response: %v", err)
|
||||||
|
}
|
||||||
|
deps.SyncResponse = resp
|
||||||
|
|
||||||
|
if e := cc.Engine(); e != nil {
|
||||||
|
if cm := e.GetClientMetrics(); cm != nil {
|
||||||
|
deps.ClientMetrics = cm
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bundleGenerator := debug.NewBundleGenerator(
|
||||||
|
deps,
|
||||||
|
debug.BundleConfig{
|
||||||
|
Anonymize: anonymize,
|
||||||
|
IncludeSystemInfo: true,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
path, err := bundleGenerator.Generate()
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("generate debug bundle: %w", err)
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
if err := os.Remove(path); err != nil {
|
||||||
|
log.Errorf("failed to remove debug bundle file: %v", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
uploadCtx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
key, err := debug.UploadDebugBundle(uploadCtx, types.DefaultBundleURL, cfg.ManagementURL.String(), path)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("upload debug bundle: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Infof("debug bundle uploaded with key %s", key)
|
||||||
|
return key, nil
|
||||||
|
}
|
||||||
|
|
||||||
// SetTraceLogLevel configure the logger to trace level
|
// SetTraceLogLevel configure the logger to trace level
|
||||||
func (c *Client) SetTraceLogLevel() {
|
func (c *Client) SetTraceLogLevel() {
|
||||||
log.SetLevel(log.TraceLevel)
|
log.SetLevel(log.TraceLevel)
|
||||||
@@ -174,9 +301,11 @@ func (c *Client) PeersList() *PeerInfoArray {
|
|||||||
peerInfos := make([]PeerInfo, len(fullStatus.Peers))
|
peerInfos := make([]PeerInfo, len(fullStatus.Peers))
|
||||||
for n, p := range fullStatus.Peers {
|
for n, p := range fullStatus.Peers {
|
||||||
pi := PeerInfo{
|
pi := PeerInfo{
|
||||||
p.IP,
|
IP: p.IP,
|
||||||
p.FQDN,
|
IPv6: p.IPv6,
|
||||||
p.ConnStatus.String(),
|
FQDN: p.FQDN,
|
||||||
|
ConnStatus: int(p.ConnStatus),
|
||||||
|
Routes: PeerRoutes{routes: maps.Keys(p.GetRoutes())},
|
||||||
}
|
}
|
||||||
peerInfos[n] = pi
|
peerInfos[n] = pi
|
||||||
}
|
}
|
||||||
@@ -184,12 +313,13 @@ func (c *Client) PeersList() *PeerInfoArray {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) Networks() *NetworkArray {
|
func (c *Client) Networks() *NetworkArray {
|
||||||
if c.connectClient == nil {
|
cc := c.getConnectClient()
|
||||||
|
if cc == nil {
|
||||||
log.Error("not connected")
|
log.Error("not connected")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
engine := c.connectClient.Engine()
|
engine := cc.Engine()
|
||||||
if engine == nil {
|
if engine == nil {
|
||||||
log.Error("could not get engine")
|
log.Error("could not get engine")
|
||||||
return nil
|
return nil
|
||||||
@@ -201,37 +331,90 @@ func (c *Client) Networks() *NetworkArray {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
routeSelector := routeManager.GetRouteSelector()
|
||||||
|
if routeSelector == nil {
|
||||||
|
log.Error("could not get route selector")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
routesMap := routeManager.GetClientRoutesWithNetID()
|
||||||
|
v6Merged := route.V6ExitMergeSet(routesMap)
|
||||||
|
resolvedDomains := c.recorder.GetResolvedDomainsStates()
|
||||||
|
|
||||||
networkArray := &NetworkArray{
|
networkArray := &NetworkArray{
|
||||||
items: make([]Network, 0),
|
items: make([]Network, 0),
|
||||||
}
|
}
|
||||||
|
|
||||||
for id, routes := range routeManager.GetClientRoutesWithNetID() {
|
for id, routes := range routesMap {
|
||||||
if len(routes) == 0 {
|
if len(routes) == 0 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
if _, skip := v6Merged[id]; skip {
|
||||||
r := routes[0]
|
|
||||||
netStr := r.Network.String()
|
|
||||||
if r.IsDynamic() {
|
|
||||||
netStr = r.Domains.SafeString()
|
|
||||||
}
|
|
||||||
|
|
||||||
peer, err := c.recorder.GetPeer(routes[0].Peer)
|
|
||||||
if err != nil {
|
|
||||||
log.Errorf("could not get peer info for %s: %v", routes[0].Peer, err)
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
network := Network{
|
|
||||||
Name: string(id),
|
network := c.buildNetwork(id, routes, routeSelector.IsSelected(id), resolvedDomains, v6Merged)
|
||||||
Network: netStr,
|
if network == nil {
|
||||||
Peer: peer.FQDN,
|
continue
|
||||||
Status: peer.ConnStatus.String(),
|
|
||||||
}
|
}
|
||||||
networkArray.Add(network)
|
networkArray.Add(*network)
|
||||||
}
|
}
|
||||||
return networkArray
|
return networkArray
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *Client) buildNetwork(id route.NetID, routes []*route.Route, selected bool, resolvedDomains map[domain.Domain]peer.ResolvedDomainInfo, v6Merged map[route.NetID]struct{}) *Network {
|
||||||
|
r := routes[0]
|
||||||
|
netStr := r.Network.String()
|
||||||
|
if r.IsDynamic() {
|
||||||
|
netStr = r.Domains.SafeString()
|
||||||
|
}
|
||||||
|
|
||||||
|
routePeer, err := c.findBestRoutePeer(routes)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("could not get peer info for route %s: %v", id, err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
network := &Network{
|
||||||
|
Name: string(id),
|
||||||
|
Network: netStr,
|
||||||
|
Peer: routePeer.FQDN,
|
||||||
|
Status: routePeer.ConnStatus.String(),
|
||||||
|
IsSelected: selected,
|
||||||
|
Domains: c.getNetworkDomainsFromRoute(r, resolvedDomains),
|
||||||
|
}
|
||||||
|
|
||||||
|
if route.IsV4DefaultRoute(r.Network) && route.HasV6ExitPair(id, v6Merged) {
|
||||||
|
network.Network = "0.0.0.0/0, ::/0"
|
||||||
|
}
|
||||||
|
|
||||||
|
return network
|
||||||
|
}
|
||||||
|
|
||||||
|
// findBestRoutePeer returns the peer actively routing traffic for the given
|
||||||
|
// HA route group. Falls back to the first connected peer, then the first peer.
|
||||||
|
func (c *Client) findBestRoutePeer(routes []*route.Route) (peer.State, error) {
|
||||||
|
netStr := routes[0].Network.String()
|
||||||
|
|
||||||
|
fullStatus := c.recorder.GetFullStatus()
|
||||||
|
for _, p := range fullStatus.Peers {
|
||||||
|
if _, ok := p.GetRoutes()[netStr]; ok {
|
||||||
|
return p, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, r := range routes {
|
||||||
|
p, err := c.recorder.GetPeer(r.Peer)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if p.ConnStatus == peer.StatusConnected {
|
||||||
|
return p, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return c.recorder.GetPeer(routes[0].Peer)
|
||||||
|
}
|
||||||
|
|
||||||
// OnUpdatedHostDNS update the DNS servers addresses for root zones
|
// OnUpdatedHostDNS update the DNS servers addresses for root zones
|
||||||
func (c *Client) OnUpdatedHostDNS(list *DNSList) error {
|
func (c *Client) OnUpdatedHostDNS(list *DNSList) error {
|
||||||
dnsServer, err := dns.GetServerDns()
|
dnsServer, err := dns.GetServerDns()
|
||||||
@@ -253,6 +436,69 @@ func (c *Client) RemoveConnectionListener() {
|
|||||||
c.recorder.RemoveConnectionListener()
|
c.recorder.RemoveConnectionListener()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *Client) toggleRoute(command routeCommand) error {
|
||||||
|
return command.toggleRoute()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) getRouteManager() (routemanager.Manager, error) {
|
||||||
|
client := c.getConnectClient()
|
||||||
|
if client == nil {
|
||||||
|
return nil, fmt.Errorf("not connected")
|
||||||
|
}
|
||||||
|
|
||||||
|
engine := client.Engine()
|
||||||
|
if engine == nil {
|
||||||
|
return nil, fmt.Errorf("engine is not running")
|
||||||
|
}
|
||||||
|
|
||||||
|
manager := engine.GetRouteManager()
|
||||||
|
if manager == nil {
|
||||||
|
return nil, fmt.Errorf("could not get route manager")
|
||||||
|
}
|
||||||
|
|
||||||
|
return manager, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) SelectRoute(route string) error {
|
||||||
|
manager, err := c.getRouteManager()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.toggleRoute(selectRouteCommand{route: route, manager: manager})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) DeselectRoute(route string) error {
|
||||||
|
manager, err := c.getRouteManager()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.toggleRoute(deselectRouteCommand{route: route, manager: manager})
|
||||||
|
}
|
||||||
|
|
||||||
|
// getNetworkDomainsFromRoute extracts domains from a route and enriches each domain
|
||||||
|
// with its resolved IP addresses from the provided resolvedDomains map.
|
||||||
|
func (c *Client) getNetworkDomainsFromRoute(route *route.Route, resolvedDomains map[domain.Domain]peer.ResolvedDomainInfo) NetworkDomains {
|
||||||
|
domains := NetworkDomains{}
|
||||||
|
|
||||||
|
for _, d := range route.Domains {
|
||||||
|
networkDomain := NetworkDomain{
|
||||||
|
Address: d.SafeString(),
|
||||||
|
}
|
||||||
|
|
||||||
|
if info, exists := resolvedDomains[d]; exists {
|
||||||
|
for _, prefix := range info.Prefixes {
|
||||||
|
networkDomain.addResolvedIP(prefix.Addr().String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
domains.Add(&networkDomain)
|
||||||
|
}
|
||||||
|
|
||||||
|
return domains
|
||||||
|
}
|
||||||
|
|
||||||
func exportEnvList(list *EnvList) {
|
func exportEnvList(list *EnvList) {
|
||||||
if list == nil {
|
if list == nil {
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -1,10 +1,19 @@
|
|||||||
package android
|
package android
|
||||||
|
|
||||||
import "github.com/netbirdio/netbird/client/internal/peer"
|
import (
|
||||||
|
"github.com/netbirdio/netbird/client/internal/lazyconn"
|
||||||
|
"github.com/netbirdio/netbird/client/internal/peer"
|
||||||
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
// EnvKeyNBForceRelay Exported for Android java client
|
// EnvKeyNBForceRelay Exported for Android java client to force relay connections
|
||||||
EnvKeyNBForceRelay = peer.EnvKeyNBForceRelay
|
EnvKeyNBForceRelay = peer.EnvKeyNBForceRelay
|
||||||
|
|
||||||
|
// EnvKeyNBLazyConn Exported for Android java client to configure lazy connection
|
||||||
|
EnvKeyNBLazyConn = lazyconn.EnvEnableLazyConn
|
||||||
|
|
||||||
|
// EnvKeyNBInactivityThreshold Exported for Android java client to configure connection inactivity threshold
|
||||||
|
EnvKeyNBInactivityThreshold = lazyconn.EnvInactivityThreshold
|
||||||
)
|
)
|
||||||
|
|
||||||
// EnvList wraps a Go map for export to Java
|
// EnvList wraps a Go map for export to Java
|
||||||
|
|||||||
@@ -3,15 +3,7 @@ package android
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/cenkalti/backoff/v4"
|
|
||||||
log "github.com/sirupsen/logrus"
|
|
||||||
"google.golang.org/grpc/codes"
|
|
||||||
gstatus "google.golang.org/grpc/status"
|
|
||||||
|
|
||||||
"github.com/netbirdio/netbird/client/cmd"
|
|
||||||
"github.com/netbirdio/netbird/client/internal"
|
|
||||||
"github.com/netbirdio/netbird/client/internal/auth"
|
"github.com/netbirdio/netbird/client/internal/auth"
|
||||||
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
||||||
"github.com/netbirdio/netbird/client/system"
|
"github.com/netbirdio/netbird/client/system"
|
||||||
@@ -32,7 +24,7 @@ type ErrListener interface {
|
|||||||
// URLOpener it is a callback interface. The Open function will be triggered if
|
// URLOpener it is a callback interface. The Open function will be triggered if
|
||||||
// the backend want to show an url for the user
|
// the backend want to show an url for the user
|
||||||
type URLOpener interface {
|
type URLOpener interface {
|
||||||
Open(string)
|
Open(url string, userCode string)
|
||||||
OnLoginSuccess()
|
OnLoginSuccess()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,34 +76,21 @@ func (a *Auth) SaveConfigIfSSOSupported(listener SSOListener) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (a *Auth) saveConfigIfSSOSupported() (bool, error) {
|
func (a *Auth) saveConfigIfSSOSupported() (bool, error) {
|
||||||
supportsSSO := true
|
authClient, err := auth.NewAuth(a.ctx, a.config.PrivateKey, a.config.ManagementURL, a.config)
|
||||||
err := a.withBackOff(a.ctx, func() (err error) {
|
if err != nil {
|
||||||
_, err = internal.GetPKCEAuthorizationFlowInfo(a.ctx, a.config.PrivateKey, a.config.ManagementURL, nil)
|
return false, fmt.Errorf("failed to create auth client: %v", err)
|
||||||
if s, ok := gstatus.FromError(err); ok && (s.Code() == codes.NotFound || s.Code() == codes.Unimplemented) {
|
}
|
||||||
_, err = internal.GetDeviceAuthorizationFlowInfo(a.ctx, a.config.PrivateKey, a.config.ManagementURL)
|
defer authClient.Close()
|
||||||
s, ok := gstatus.FromError(err)
|
|
||||||
if !ok {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if s.Code() == codes.NotFound || s.Code() == codes.Unimplemented {
|
|
||||||
supportsSSO = false
|
|
||||||
err = nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return err
|
supportsSSO, err := authClient.IsSSOSupported(a.ctx)
|
||||||
}
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("failed to check SSO support: %v", err)
|
||||||
return err
|
}
|
||||||
})
|
|
||||||
|
|
||||||
if !supportsSSO {
|
if !supportsSSO {
|
||||||
return false, nil
|
return false, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
return false, fmt.Errorf("backoff cycle failed: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
err = profilemanager.WriteOutConfig(a.cfgPath, a.config)
|
err = profilemanager.WriteOutConfig(a.cfgPath, a.config)
|
||||||
return true, err
|
return true, err
|
||||||
}
|
}
|
||||||
@@ -129,28 +108,26 @@ func (a *Auth) LoginWithSetupKeyAndSaveConfig(resultListener ErrListener, setupK
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (a *Auth) loginWithSetupKeyAndSaveConfig(setupKey string, deviceName string) error {
|
func (a *Auth) loginWithSetupKeyAndSaveConfig(setupKey string, deviceName string) error {
|
||||||
|
authClient, err := auth.NewAuth(a.ctx, a.config.PrivateKey, a.config.ManagementURL, a.config)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create auth client: %v", err)
|
||||||
|
}
|
||||||
|
defer authClient.Close()
|
||||||
|
|
||||||
//nolint
|
//nolint
|
||||||
ctxWithValues := context.WithValue(a.ctx, system.DeviceNameCtxKey, deviceName)
|
ctxWithValues := context.WithValue(a.ctx, system.DeviceNameCtxKey, deviceName)
|
||||||
|
err, _ = authClient.Login(ctxWithValues, setupKey, "")
|
||||||
err := a.withBackOff(a.ctx, func() error {
|
|
||||||
backoffErr := internal.Login(ctxWithValues, a.config, setupKey, "")
|
|
||||||
if s, ok := gstatus.FromError(backoffErr); ok && (s.Code() == codes.PermissionDenied) {
|
|
||||||
// we got an answer from management, exit backoff earlier
|
|
||||||
return backoff.Permanent(backoffErr)
|
|
||||||
}
|
|
||||||
return backoffErr
|
|
||||||
})
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("backoff cycle failed: %v", err)
|
return fmt.Errorf("login failed: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return profilemanager.WriteOutConfig(a.cfgPath, a.config)
|
return profilemanager.WriteOutConfig(a.cfgPath, a.config)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Login try register the client on the server
|
// Login try register the client on the server
|
||||||
func (a *Auth) Login(resultListener ErrListener, urlOpener URLOpener) {
|
func (a *Auth) Login(resultListener ErrListener, urlOpener URLOpener, isAndroidTV bool) {
|
||||||
go func() {
|
go func() {
|
||||||
err := a.login(urlOpener)
|
err := a.login(urlOpener, isAndroidTV)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
resultListener.OnError(err)
|
resultListener.OnError(err)
|
||||||
} else {
|
} else {
|
||||||
@@ -159,50 +136,42 @@ func (a *Auth) Login(resultListener ErrListener, urlOpener URLOpener) {
|
|||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *Auth) login(urlOpener URLOpener) error {
|
func (a *Auth) login(urlOpener URLOpener, isAndroidTV bool) error {
|
||||||
var needsLogin bool
|
authClient, err := auth.NewAuth(a.ctx, a.config.PrivateKey, a.config.ManagementURL, a.config)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create auth client: %v", err)
|
||||||
|
}
|
||||||
|
defer authClient.Close()
|
||||||
|
|
||||||
// check if we need to generate JWT token
|
// check if we need to generate JWT token
|
||||||
err := a.withBackOff(a.ctx, func() (err error) {
|
needsLogin, err := authClient.IsLoginRequired(a.ctx)
|
||||||
needsLogin, err = internal.IsLoginRequired(a.ctx, a.config)
|
|
||||||
return
|
|
||||||
})
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("backoff cycle failed: %v", err)
|
return fmt.Errorf("failed to check login requirement: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
jwtToken := ""
|
jwtToken := ""
|
||||||
if needsLogin {
|
if needsLogin {
|
||||||
tokenInfo, err := a.foregroundGetTokenInfo(urlOpener)
|
tokenInfo, err := a.foregroundGetTokenInfo(authClient, urlOpener, isAndroidTV)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("interactive sso login failed: %v", err)
|
return fmt.Errorf("interactive sso login failed: %v", err)
|
||||||
}
|
}
|
||||||
jwtToken = tokenInfo.GetTokenToUse()
|
jwtToken = tokenInfo.GetTokenToUse()
|
||||||
}
|
}
|
||||||
|
|
||||||
err = a.withBackOff(a.ctx, func() error {
|
err, _ = authClient.Login(a.ctx, "", jwtToken)
|
||||||
err := internal.Login(a.ctx, a.config, "", jwtToken)
|
|
||||||
|
|
||||||
if err == nil {
|
|
||||||
go urlOpener.OnLoginSuccess()
|
|
||||||
}
|
|
||||||
|
|
||||||
if s, ok := gstatus.FromError(err); ok && (s.Code() == codes.InvalidArgument || s.Code() == codes.PermissionDenied) {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return err
|
|
||||||
})
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("backoff cycle failed: %v", err)
|
return fmt.Errorf("login failed: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
go urlOpener.OnLoginSuccess()
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *Auth) foregroundGetTokenInfo(urlOpener URLOpener) (*auth.TokenInfo, error) {
|
func (a *Auth) foregroundGetTokenInfo(authClient *auth.Auth, urlOpener URLOpener, isAndroidTV bool) (*auth.TokenInfo, error) {
|
||||||
oAuthFlow, err := auth.NewOAuthFlow(a.ctx, a.config, false)
|
oAuthFlow, err := authClient.GetOAuthFlow(a.ctx, isAndroidTV)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, fmt.Errorf("failed to get OAuth flow: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
flowInfo, err := oAuthFlow.RequestAuthInfo(context.TODO())
|
flowInfo, err := oAuthFlow.RequestAuthInfo(context.TODO())
|
||||||
@@ -210,24 +179,12 @@ func (a *Auth) foregroundGetTokenInfo(urlOpener URLOpener) (*auth.TokenInfo, err
|
|||||||
return nil, fmt.Errorf("getting a request OAuth flow info failed: %v", err)
|
return nil, fmt.Errorf("getting a request OAuth flow info failed: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
go urlOpener.Open(flowInfo.VerificationURIComplete)
|
go urlOpener.Open(flowInfo.VerificationURIComplete, flowInfo.UserCode)
|
||||||
|
|
||||||
waitTimeout := time.Duration(flowInfo.ExpiresIn) * time.Second
|
tokenInfo, err := oAuthFlow.WaitToken(a.ctx, flowInfo)
|
||||||
waitCTX, cancel := context.WithTimeout(a.ctx, waitTimeout)
|
|
||||||
defer cancel()
|
|
||||||
tokenInfo, err := oAuthFlow.WaitToken(waitCTX, flowInfo)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("waiting for browser login failed: %v", err)
|
return nil, fmt.Errorf("waiting for browser login failed: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return &tokenInfo, nil
|
return &tokenInfo, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *Auth) withBackOff(ctx context.Context, bf func() error) error {
|
|
||||||
return backoff.RetryNotify(
|
|
||||||
bf,
|
|
||||||
backoff.WithContext(cmd.CLIBackOffSettings, ctx),
|
|
||||||
func(err error, duration time.Duration) {
|
|
||||||
log.Warnf("retrying Login to the Management service in %v due to error %v", duration, err)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|||||||
56
client/android/network_domains.go
Normal file
56
client/android/network_domains.go
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
//go:build android
|
||||||
|
|
||||||
|
package android
|
||||||
|
|
||||||
|
import "fmt"
|
||||||
|
|
||||||
|
type ResolvedIPs struct {
|
||||||
|
resolvedIPs []string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ResolvedIPs) Add(ipAddress string) {
|
||||||
|
r.resolvedIPs = append(r.resolvedIPs, ipAddress)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ResolvedIPs) Get(i int) (string, error) {
|
||||||
|
if i < 0 || i >= len(r.resolvedIPs) {
|
||||||
|
return "", fmt.Errorf("%d is out of range", i)
|
||||||
|
}
|
||||||
|
return r.resolvedIPs[i], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ResolvedIPs) Size() int {
|
||||||
|
return len(r.resolvedIPs)
|
||||||
|
}
|
||||||
|
|
||||||
|
type NetworkDomain struct {
|
||||||
|
Address string
|
||||||
|
resolvedIPs ResolvedIPs
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *NetworkDomain) addResolvedIP(resolvedIP string) {
|
||||||
|
d.resolvedIPs.Add(resolvedIP)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *NetworkDomain) GetResolvedIPs() *ResolvedIPs {
|
||||||
|
return &d.resolvedIPs
|
||||||
|
}
|
||||||
|
|
||||||
|
type NetworkDomains struct {
|
||||||
|
domains []*NetworkDomain
|
||||||
|
}
|
||||||
|
|
||||||
|
func (n *NetworkDomains) Add(domain *NetworkDomain) {
|
||||||
|
n.domains = append(n.domains, domain)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (n *NetworkDomains) Get(i int) (*NetworkDomain, error) {
|
||||||
|
if i < 0 || i >= len(n.domains) {
|
||||||
|
return nil, fmt.Errorf("%d is out of range", i)
|
||||||
|
}
|
||||||
|
return n.domains[i], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (n *NetworkDomains) Size() int {
|
||||||
|
return len(n.domains)
|
||||||
|
}
|
||||||
@@ -3,10 +3,16 @@
|
|||||||
package android
|
package android
|
||||||
|
|
||||||
type Network struct {
|
type Network struct {
|
||||||
Name string
|
Name string
|
||||||
Network string
|
Network string
|
||||||
Peer string
|
Peer string
|
||||||
Status string
|
Status string
|
||||||
|
IsSelected bool
|
||||||
|
Domains NetworkDomains
|
||||||
|
}
|
||||||
|
|
||||||
|
func (n Network) GetNetworkDomains() *NetworkDomains {
|
||||||
|
return &n.Domains
|
||||||
}
|
}
|
||||||
|
|
||||||
type NetworkArray struct {
|
type NetworkArray struct {
|
||||||
|
|||||||
@@ -1,10 +1,27 @@
|
|||||||
|
//go:build android
|
||||||
|
|
||||||
package android
|
package android
|
||||||
|
|
||||||
|
import "github.com/netbirdio/netbird/client/internal/peer"
|
||||||
|
|
||||||
|
// Connection status constants exported via gomobile.
|
||||||
|
const (
|
||||||
|
ConnStatusIdle = int(peer.StatusIdle)
|
||||||
|
ConnStatusConnecting = int(peer.StatusConnecting)
|
||||||
|
ConnStatusConnected = int(peer.StatusConnected)
|
||||||
|
)
|
||||||
|
|
||||||
// PeerInfo describe information about the peers. It designed for the UI usage
|
// PeerInfo describe information about the peers. It designed for the UI usage
|
||||||
type PeerInfo struct {
|
type PeerInfo struct {
|
||||||
IP string
|
IP string
|
||||||
|
IPv6 string
|
||||||
FQDN string
|
FQDN string
|
||||||
ConnStatus string // Todo replace to enum
|
ConnStatus int
|
||||||
|
Routes PeerRoutes
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *PeerInfo) GetPeerRoutes() *PeerRoutes {
|
||||||
|
return &p.Routes
|
||||||
}
|
}
|
||||||
|
|
||||||
// PeerInfoArray is a wrapper of []PeerInfo
|
// PeerInfoArray is a wrapper of []PeerInfo
|
||||||
|
|||||||
20
client/android/peer_routes.go
Normal file
20
client/android/peer_routes.go
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
//go:build android
|
||||||
|
|
||||||
|
package android
|
||||||
|
|
||||||
|
import "fmt"
|
||||||
|
|
||||||
|
type PeerRoutes struct {
|
||||||
|
routes []string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *PeerRoutes) Get(i int) (string, error) {
|
||||||
|
if i < 0 || i >= len(p.routes) {
|
||||||
|
return "", fmt.Errorf("%d is out of range", i)
|
||||||
|
}
|
||||||
|
return p.routes[i], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *PeerRoutes) Size() int {
|
||||||
|
return len(p.routes)
|
||||||
|
}
|
||||||
11
client/android/platform_files.go
Normal file
11
client/android/platform_files.go
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
//go:build android
|
||||||
|
|
||||||
|
package android
|
||||||
|
|
||||||
|
// PlatformFiles groups paths to files used internally by the engine that can't be created/modified
|
||||||
|
// at their default locations due to android OS restrictions.
|
||||||
|
type PlatformFiles interface {
|
||||||
|
ConfigurationFilePath() string
|
||||||
|
StateFilePath() string
|
||||||
|
CacheDir() string
|
||||||
|
}
|
||||||
@@ -307,6 +307,24 @@ func (p *Preferences) SetBlockInbound(block bool) {
|
|||||||
p.configInput.BlockInbound = &block
|
p.configInput.BlockInbound = &block
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetDisableIPv6 reads disable IPv6 setting from config file
|
||||||
|
func (p *Preferences) GetDisableIPv6() (bool, error) {
|
||||||
|
if p.configInput.DisableIPv6 != nil {
|
||||||
|
return *p.configInput.DisableIPv6, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err := profilemanager.ReadConfig(p.configInput.ConfigPath)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return cfg.DisableIPv6, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetDisableIPv6 stores the given value and waits for commit
|
||||||
|
func (p *Preferences) SetDisableIPv6(disable bool) {
|
||||||
|
p.configInput.DisableIPv6 = &disable
|
||||||
|
}
|
||||||
|
|
||||||
// Commit writes out the changes to the config file
|
// Commit writes out the changes to the config file
|
||||||
func (p *Preferences) Commit() error {
|
func (p *Preferences) Commit() error {
|
||||||
_, err := profilemanager.UpdateOrCreateConfig(p.configInput)
|
_, err := profilemanager.UpdateOrCreateConfig(p.configInput)
|
||||||
|
|||||||
257
client/android/profile_manager.go
Normal file
257
client/android/profile_manager.go
Normal file
@@ -0,0 +1,257 @@
|
|||||||
|
//go:build android
|
||||||
|
|
||||||
|
package android
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
|
|
||||||
|
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// Android-specific config filename (different from desktop default.json)
|
||||||
|
defaultConfigFilename = "netbird.cfg"
|
||||||
|
// Subdirectory for non-default profiles (must match Java Preferences.java)
|
||||||
|
profilesSubdir = "profiles"
|
||||||
|
// Android uses a single user context per app (non-empty username required by ServiceManager)
|
||||||
|
androidUsername = "android"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Profile represents a profile for gomobile
|
||||||
|
type Profile struct {
|
||||||
|
Name string
|
||||||
|
IsActive bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProfileArray wraps profiles for gomobile compatibility
|
||||||
|
type ProfileArray struct {
|
||||||
|
items []*Profile
|
||||||
|
}
|
||||||
|
|
||||||
|
// Length returns the number of profiles
|
||||||
|
func (p *ProfileArray) Length() int {
|
||||||
|
return len(p.items)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get returns the profile at index i
|
||||||
|
func (p *ProfileArray) Get(i int) *Profile {
|
||||||
|
if i < 0 || i >= len(p.items) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return p.items[i]
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
|
||||||
|
/data/data/io.netbird.client/files/ ← configDir parameter
|
||||||
|
├── netbird.cfg ← Default profile config
|
||||||
|
├── state.json ← Default profile state
|
||||||
|
├── active_profile.json ← Active profile tracker (JSON with Name + Username)
|
||||||
|
└── profiles/ ← Subdirectory for non-default profiles
|
||||||
|
├── work.json ← Work profile config
|
||||||
|
├── work.state.json ← Work profile state
|
||||||
|
├── personal.json ← Personal profile config
|
||||||
|
└── personal.state.json ← Personal profile state
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ProfileManager manages profiles for Android
|
||||||
|
// It wraps the internal profilemanager to provide Android-specific behavior
|
||||||
|
type ProfileManager struct {
|
||||||
|
configDir string
|
||||||
|
serviceMgr *profilemanager.ServiceManager
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewProfileManager creates a new profile manager for Android
|
||||||
|
func NewProfileManager(configDir string) *ProfileManager {
|
||||||
|
// Set the default config path for Android (stored in root configDir, not profiles/)
|
||||||
|
defaultConfigPath := filepath.Join(configDir, defaultConfigFilename)
|
||||||
|
|
||||||
|
// Set global paths for Android
|
||||||
|
profilemanager.DefaultConfigPathDir = configDir
|
||||||
|
profilemanager.DefaultConfigPath = defaultConfigPath
|
||||||
|
profilemanager.ActiveProfileStatePath = filepath.Join(configDir, "active_profile.json")
|
||||||
|
|
||||||
|
// Create ServiceManager with profiles/ subdirectory
|
||||||
|
// This avoids modifying the global ConfigDirOverride for profile listing
|
||||||
|
profilesDir := filepath.Join(configDir, profilesSubdir)
|
||||||
|
serviceMgr := profilemanager.NewServiceManagerWithProfilesDir(defaultConfigPath, profilesDir)
|
||||||
|
|
||||||
|
return &ProfileManager{
|
||||||
|
configDir: configDir,
|
||||||
|
serviceMgr: serviceMgr,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListProfiles returns all available profiles
|
||||||
|
func (pm *ProfileManager) ListProfiles() (*ProfileArray, error) {
|
||||||
|
// Use ServiceManager (looks in profiles/ directory, checks active_profile.json for IsActive)
|
||||||
|
internalProfiles, err := pm.serviceMgr.ListProfiles(androidUsername)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to list profiles: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert internal profiles to Android Profile type
|
||||||
|
var profiles []*Profile
|
||||||
|
for _, p := range internalProfiles {
|
||||||
|
profiles = append(profiles, &Profile{
|
||||||
|
Name: p.Name,
|
||||||
|
IsActive: p.IsActive,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return &ProfileArray{items: profiles}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetActiveProfile returns the currently active profile name
|
||||||
|
func (pm *ProfileManager) GetActiveProfile() (string, error) {
|
||||||
|
// Use ServiceManager to stay consistent with ListProfiles
|
||||||
|
// ServiceManager uses active_profile.json
|
||||||
|
activeState, err := pm.serviceMgr.GetActiveProfileState()
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to get active profile: %w", err)
|
||||||
|
}
|
||||||
|
return activeState.Name, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SwitchProfile switches to a different profile
|
||||||
|
func (pm *ProfileManager) SwitchProfile(profileName string) error {
|
||||||
|
// Use ServiceManager to stay consistent with ListProfiles
|
||||||
|
// ServiceManager uses active_profile.json
|
||||||
|
err := pm.serviceMgr.SetActiveProfileState(&profilemanager.ActiveProfileState{
|
||||||
|
Name: profileName,
|
||||||
|
Username: androidUsername,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to switch profile: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Infof("switched to profile: %s", profileName)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddProfile creates a new profile
|
||||||
|
func (pm *ProfileManager) AddProfile(profileName string) error {
|
||||||
|
// Use ServiceManager (creates profile in profiles/ directory)
|
||||||
|
if err := pm.serviceMgr.AddProfile(profileName, androidUsername); err != nil {
|
||||||
|
return fmt.Errorf("failed to add profile: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Infof("created new profile: %s", profileName)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// LogoutProfile logs out from a profile (clears authentication)
|
||||||
|
func (pm *ProfileManager) LogoutProfile(profileName string) error {
|
||||||
|
profileName = sanitizeProfileName(profileName)
|
||||||
|
|
||||||
|
configPath, err := pm.getProfileConfigPath(profileName)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if profile exists
|
||||||
|
if _, err := os.Stat(configPath); os.IsNotExist(err) {
|
||||||
|
return fmt.Errorf("profile '%s' does not exist", profileName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read current config using internal profilemanager
|
||||||
|
config, err := profilemanager.ReadConfig(configPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to read profile config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear authentication by removing private key and SSH key
|
||||||
|
config.PrivateKey = ""
|
||||||
|
config.SSHKey = ""
|
||||||
|
|
||||||
|
// Save config using internal profilemanager
|
||||||
|
if err := profilemanager.WriteOutConfig(configPath, config); err != nil {
|
||||||
|
return fmt.Errorf("failed to save config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Infof("logged out from profile: %s", profileName)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoveProfile deletes a profile
|
||||||
|
func (pm *ProfileManager) RemoveProfile(profileName string) error {
|
||||||
|
// Use ServiceManager (removes profile from profiles/ directory)
|
||||||
|
if err := pm.serviceMgr.RemoveProfile(profileName, androidUsername); err != nil {
|
||||||
|
return fmt.Errorf("failed to remove profile: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Infof("removed profile: %s", profileName)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// getProfileConfigPath returns the config file path for a profile
|
||||||
|
// This is needed for Android-specific path handling (netbird.cfg for default profile)
|
||||||
|
func (pm *ProfileManager) getProfileConfigPath(profileName string) (string, error) {
|
||||||
|
if profileName == "" || profileName == profilemanager.DefaultProfileName {
|
||||||
|
// Android uses netbird.cfg for default profile instead of default.json
|
||||||
|
// Default profile is stored in root configDir, not in profiles/
|
||||||
|
return filepath.Join(pm.configDir, defaultConfigFilename), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Non-default profiles are stored in profiles subdirectory
|
||||||
|
// This matches the Java Preferences.java expectation
|
||||||
|
profileName = sanitizeProfileName(profileName)
|
||||||
|
profilesDir := filepath.Join(pm.configDir, profilesSubdir)
|
||||||
|
return filepath.Join(profilesDir, profileName+".json"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetConfigPath returns the config file path for a given profile
|
||||||
|
// Java should call this instead of constructing paths with Preferences.configFile()
|
||||||
|
func (pm *ProfileManager) GetConfigPath(profileName string) (string, error) {
|
||||||
|
return pm.getProfileConfigPath(profileName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetStateFilePath returns the state file path for a given profile
|
||||||
|
// Java should call this instead of constructing paths with Preferences.stateFile()
|
||||||
|
func (pm *ProfileManager) GetStateFilePath(profileName string) (string, error) {
|
||||||
|
if profileName == "" || profileName == profilemanager.DefaultProfileName {
|
||||||
|
return filepath.Join(pm.configDir, "state.json"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
profileName = sanitizeProfileName(profileName)
|
||||||
|
profilesDir := filepath.Join(pm.configDir, profilesSubdir)
|
||||||
|
return filepath.Join(profilesDir, profileName+".state.json"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetActiveConfigPath returns the config file path for the currently active profile
|
||||||
|
// Java should call this instead of Preferences.getActiveProfileName() + Preferences.configFile()
|
||||||
|
func (pm *ProfileManager) GetActiveConfigPath() (string, error) {
|
||||||
|
activeProfile, err := pm.GetActiveProfile()
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to get active profile: %w", err)
|
||||||
|
}
|
||||||
|
return pm.GetConfigPath(activeProfile)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetActiveStateFilePath returns the state file path for the currently active profile
|
||||||
|
// Java should call this instead of Preferences.getActiveProfileName() + Preferences.stateFile()
|
||||||
|
func (pm *ProfileManager) GetActiveStateFilePath() (string, error) {
|
||||||
|
activeProfile, err := pm.GetActiveProfile()
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to get active profile: %w", err)
|
||||||
|
}
|
||||||
|
return pm.GetStateFilePath(activeProfile)
|
||||||
|
}
|
||||||
|
|
||||||
|
// sanitizeProfileName removes invalid characters from profile name
|
||||||
|
func sanitizeProfileName(name string) string {
|
||||||
|
// Keep only alphanumeric, underscore, and hyphen
|
||||||
|
var result strings.Builder
|
||||||
|
for _, r := range name {
|
||||||
|
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') ||
|
||||||
|
(r >= '0' && r <= '9') || r == '_' || r == '-' {
|
||||||
|
result.WriteRune(r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result.String()
|
||||||
|
}
|
||||||
70
client/android/route_command.go
Normal file
70
client/android/route_command.go
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
//go:build android
|
||||||
|
|
||||||
|
package android
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
|
"golang.org/x/exp/maps"
|
||||||
|
|
||||||
|
"github.com/netbirdio/netbird/client/internal/routemanager"
|
||||||
|
"github.com/netbirdio/netbird/route"
|
||||||
|
)
|
||||||
|
|
||||||
|
func executeRouteToggle(id string, manager routemanager.Manager,
|
||||||
|
operationName string,
|
||||||
|
routeOperation func(routes []route.NetID, allRoutes []route.NetID) error) error {
|
||||||
|
netID := route.NetID(id)
|
||||||
|
routes := []route.NetID{netID}
|
||||||
|
|
||||||
|
routesMap := manager.GetClientRoutesWithNetID()
|
||||||
|
routes = route.ExpandV6ExitPairs(routes, routesMap)
|
||||||
|
|
||||||
|
log.Debugf("%s with ids: %v", operationName, routes)
|
||||||
|
|
||||||
|
if err := routeOperation(routes, maps.Keys(routesMap)); err != nil {
|
||||||
|
log.Debugf("error when %s: %s", operationName, err)
|
||||||
|
return fmt.Errorf("error %s: %w", operationName, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
manager.TriggerSelection(manager.GetClientRoutes())
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type routeCommand interface {
|
||||||
|
toggleRoute() error
|
||||||
|
}
|
||||||
|
|
||||||
|
type selectRouteCommand struct {
|
||||||
|
route string
|
||||||
|
manager routemanager.Manager
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s selectRouteCommand) toggleRoute() error {
|
||||||
|
routeSelector := s.manager.GetRouteSelector()
|
||||||
|
if routeSelector == nil {
|
||||||
|
return fmt.Errorf("no route selector available")
|
||||||
|
}
|
||||||
|
|
||||||
|
routeOperation := func(routes []route.NetID, allRoutes []route.NetID) error {
|
||||||
|
return routeSelector.SelectRoutes(routes, true, allRoutes)
|
||||||
|
}
|
||||||
|
|
||||||
|
return executeRouteToggle(s.route, s.manager, "selecting route", routeOperation)
|
||||||
|
}
|
||||||
|
|
||||||
|
type deselectRouteCommand struct {
|
||||||
|
route string
|
||||||
|
manager routemanager.Manager
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d deselectRouteCommand) toggleRoute() error {
|
||||||
|
routeSelector := d.manager.GetRouteSelector()
|
||||||
|
if routeSelector == nil {
|
||||||
|
return fmt.Errorf("no route selector available")
|
||||||
|
}
|
||||||
|
|
||||||
|
return executeRouteToggle(d.route, d.manager, "deselecting route", routeSelector.DeselectRoutes)
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"net/url"
|
"net/url"
|
||||||
"regexp"
|
"regexp"
|
||||||
"slices"
|
"slices"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -26,8 +27,9 @@ type Anonymizer struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func DefaultAddresses() (netip.Addr, netip.Addr) {
|
func DefaultAddresses() (netip.Addr, netip.Addr) {
|
||||||
// 198.51.100.0, 100::
|
// 198.51.100.0 (RFC 5737 TEST-NET-2), 2001:db8:ffff:: (RFC 3849 documentation, last /48)
|
||||||
return netip.AddrFrom4([4]byte{198, 51, 100, 0}), netip.AddrFrom16([16]byte{0x01})
|
// The old start 100:: (discard, RFC 6666) is now used for fake IPs on Android.
|
||||||
|
return netip.AddrFrom4([4]byte{198, 51, 100, 0}), netip.MustParseAddr("2001:db8:ffff::")
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewAnonymizer(startIPv4, startIPv6 netip.Addr) *Anonymizer {
|
func NewAnonymizer(startIPv4, startIPv6 netip.Addr) *Anonymizer {
|
||||||
@@ -48,7 +50,7 @@ func (a *Anonymizer) AnonymizeIP(ip netip.Addr) netip.Addr {
|
|||||||
ip.IsLinkLocalUnicast() ||
|
ip.IsLinkLocalUnicast() ||
|
||||||
ip.IsLinkLocalMulticast() ||
|
ip.IsLinkLocalMulticast() ||
|
||||||
ip.IsInterfaceLocalMulticast() ||
|
ip.IsInterfaceLocalMulticast() ||
|
||||||
ip.IsPrivate() ||
|
(ip.Is4() && ip.IsPrivate()) ||
|
||||||
ip.IsUnspecified() ||
|
ip.IsUnspecified() ||
|
||||||
ip.IsMulticast() ||
|
ip.IsMulticast() ||
|
||||||
isWellKnown(ip) ||
|
isWellKnown(ip) ||
|
||||||
@@ -96,6 +98,11 @@ func (a *Anonymizer) isInAnonymizedRange(ip netip.Addr) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (a *Anonymizer) AnonymizeIPString(ip string) string {
|
func (a *Anonymizer) AnonymizeIPString(ip string) string {
|
||||||
|
// Handle CIDR notation (e.g. "2001:db8::/32")
|
||||||
|
if prefix, err := netip.ParsePrefix(ip); err == nil {
|
||||||
|
return a.AnonymizeIP(prefix.Addr()).String() + "/" + strconv.Itoa(prefix.Bits())
|
||||||
|
}
|
||||||
|
|
||||||
addr, err := netip.ParseAddr(ip)
|
addr, err := netip.ParseAddr(ip)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return ip
|
return ip
|
||||||
@@ -150,7 +157,7 @@ func (a *Anonymizer) AnonymizeURI(uri string) string {
|
|||||||
if u.Opaque != "" {
|
if u.Opaque != "" {
|
||||||
host, port, err := net.SplitHostPort(u.Opaque)
|
host, port, err := net.SplitHostPort(u.Opaque)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
anonymizedHost = fmt.Sprintf("%s:%s", a.AnonymizeDomain(host), port)
|
anonymizedHost = net.JoinHostPort(a.AnonymizeDomain(host), port)
|
||||||
} else {
|
} else {
|
||||||
anonymizedHost = a.AnonymizeDomain(u.Opaque)
|
anonymizedHost = a.AnonymizeDomain(u.Opaque)
|
||||||
}
|
}
|
||||||
@@ -158,7 +165,7 @@ func (a *Anonymizer) AnonymizeURI(uri string) string {
|
|||||||
} else if u.Host != "" {
|
} else if u.Host != "" {
|
||||||
host, port, err := net.SplitHostPort(u.Host)
|
host, port, err := net.SplitHostPort(u.Host)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
anonymizedHost = fmt.Sprintf("%s:%s", a.AnonymizeDomain(host), port)
|
anonymizedHost = net.JoinHostPort(a.AnonymizeDomain(host), port)
|
||||||
} else {
|
} else {
|
||||||
anonymizedHost = a.AnonymizeDomain(u.Host)
|
anonymizedHost = a.AnonymizeDomain(u.Host)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import (
|
|||||||
|
|
||||||
func TestAnonymizeIP(t *testing.T) {
|
func TestAnonymizeIP(t *testing.T) {
|
||||||
startIPv4 := netip.MustParseAddr("198.51.100.0")
|
startIPv4 := netip.MustParseAddr("198.51.100.0")
|
||||||
startIPv6 := netip.MustParseAddr("100::")
|
startIPv6 := netip.MustParseAddr("2001:db8:ffff::")
|
||||||
anonymizer := anonymize.NewAnonymizer(startIPv4, startIPv6)
|
anonymizer := anonymize.NewAnonymizer(startIPv4, startIPv6)
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
@@ -26,9 +26,9 @@ func TestAnonymizeIP(t *testing.T) {
|
|||||||
{"Second Public IPv4", "4.3.2.1", "198.51.100.1"},
|
{"Second Public IPv4", "4.3.2.1", "198.51.100.1"},
|
||||||
{"Repeated IPv4", "1.2.3.4", "198.51.100.0"},
|
{"Repeated IPv4", "1.2.3.4", "198.51.100.0"},
|
||||||
{"Private IPv4", "192.168.1.1", "192.168.1.1"},
|
{"Private IPv4", "192.168.1.1", "192.168.1.1"},
|
||||||
{"First Public IPv6", "2607:f8b0:4005:805::200e", "100::"},
|
{"First Public IPv6", "2607:f8b0:4005:805::200e", "2001:db8:ffff::"},
|
||||||
{"Second Public IPv6", "a::b", "100::1"},
|
{"Second Public IPv6", "a::b", "2001:db8:ffff::1"},
|
||||||
{"Repeated IPv6", "2607:f8b0:4005:805::200e", "100::"},
|
{"Repeated IPv6", "2607:f8b0:4005:805::200e", "2001:db8:ffff::"},
|
||||||
{"Private IPv6", "fe80::1", "fe80::1"},
|
{"Private IPv6", "fe80::1", "fe80::1"},
|
||||||
{"In Range IPv4", "198.51.100.2", "198.51.100.2"},
|
{"In Range IPv4", "198.51.100.2", "198.51.100.2"},
|
||||||
}
|
}
|
||||||
@@ -274,17 +274,27 @@ func TestAnonymizeString_IPAddresses(t *testing.T) {
|
|||||||
{
|
{
|
||||||
name: "IPv6 Address",
|
name: "IPv6 Address",
|
||||||
input: "Access attempted from 2001:db8::ff00:42",
|
input: "Access attempted from 2001:db8::ff00:42",
|
||||||
expect: "Access attempted from 100::",
|
expect: "Access attempted from 2001:db8:ffff::",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "IPv6 Address with Port",
|
name: "IPv6 Address with Port",
|
||||||
input: "Access attempted from [2001:db8::ff00:42]:8080",
|
input: "Access attempted from [2001:db8::ff00:42]:8080",
|
||||||
expect: "Access attempted from [100::]:8080",
|
expect: "Access attempted from [2001:db8:ffff::]:8080",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Both IPv4 and IPv6",
|
name: "Both IPv4 and IPv6",
|
||||||
input: "IPv4: 142.108.0.1 and IPv6: 2001:db8::ff00:43",
|
input: "IPv4: 142.108.0.1 and IPv6: 2001:db8::ff00:43",
|
||||||
expect: "IPv4: 198.51.100.1 and IPv6: 100::1",
|
expect: "IPv4: 198.51.100.1 and IPv6: 2001:db8:ffff::1",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "STUN URI with IPv6",
|
||||||
|
input: "Connecting to stun:[2001:db8::ff00:42]:3478",
|
||||||
|
expect: "Connecting to stun:[2001:db8:ffff::]:3478",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "HTTPS URI with IPv6",
|
||||||
|
input: "Visit https://[2001:db8::ff00:42]:443/path",
|
||||||
|
expect: "Visit https://[2001:db8:ffff::]:443/path",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
196
client/cmd/capture.go
Normal file
196
client/cmd/capture.go
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"syscall"
|
||||||
|
|
||||||
|
"github.com/hashicorp/go-multierror"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
"google.golang.org/grpc/status"
|
||||||
|
"google.golang.org/protobuf/types/known/durationpb"
|
||||||
|
|
||||||
|
nberrors "github.com/netbirdio/netbird/client/errors"
|
||||||
|
"github.com/netbirdio/netbird/client/proto"
|
||||||
|
"github.com/netbirdio/netbird/util/capture"
|
||||||
|
)
|
||||||
|
|
||||||
|
var captureCmd = &cobra.Command{
|
||||||
|
Use: "capture",
|
||||||
|
Short: "Capture packets on the WireGuard interface",
|
||||||
|
Long: `Captures decrypted packets flowing through the WireGuard interface.
|
||||||
|
|
||||||
|
Default output is human-readable text. Use --pcap or --output for pcap binary.
|
||||||
|
Requires --enable-capture to be set at service install or reconfigure time.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
netbird debug capture
|
||||||
|
netbird debug capture host 100.64.0.1 and port 443
|
||||||
|
netbird debug capture tcp
|
||||||
|
netbird debug capture icmp
|
||||||
|
netbird debug capture src host 10.0.0.1 and dst port 80
|
||||||
|
netbird debug capture -o capture.pcap
|
||||||
|
netbird debug capture --pcap | tshark -r -
|
||||||
|
netbird debug capture --pcap | tcpdump -r - -n`,
|
||||||
|
Args: cobra.ArbitraryArgs,
|
||||||
|
RunE: runCapture,
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
debugCmd.AddCommand(captureCmd)
|
||||||
|
|
||||||
|
captureCmd.Flags().Bool("pcap", false, "Force pcap binary output (default when --output is set)")
|
||||||
|
captureCmd.Flags().BoolP("verbose", "v", false, "Show seq/ack, TTL, window, total length")
|
||||||
|
captureCmd.Flags().Bool("ascii", false, "Print payload as ASCII after each packet (useful for HTTP)")
|
||||||
|
captureCmd.Flags().Uint32("snap-len", 0, "Max bytes per packet (0 = full)")
|
||||||
|
captureCmd.Flags().DurationP("duration", "d", 0, "Capture duration (0 = until interrupted)")
|
||||||
|
captureCmd.Flags().StringP("output", "o", "", "Write pcap to file instead of stdout")
|
||||||
|
}
|
||||||
|
|
||||||
|
func runCapture(cmd *cobra.Command, args []string) error {
|
||||||
|
conn, err := getClient(cmd)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
if err := conn.Close(); err != nil {
|
||||||
|
cmd.PrintErrf(errCloseConnection, err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
client := proto.NewDaemonServiceClient(conn)
|
||||||
|
|
||||||
|
req, err := buildCaptureRequest(cmd, args)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := signal.NotifyContext(cmd.Context(), syscall.SIGINT, syscall.SIGTERM)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
stream, err := client.StartCapture(ctx, req)
|
||||||
|
if err != nil {
|
||||||
|
return handleCaptureError(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// First Recv is the empty acceptance message from the server. If the
|
||||||
|
// device is unavailable (kernel WG, not connected, capture disabled),
|
||||||
|
// the server returns an error instead.
|
||||||
|
if _, err := stream.Recv(); err != nil {
|
||||||
|
return handleCaptureError(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
out, cleanup, err := captureOutput(cmd)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.TextOutput {
|
||||||
|
cmd.PrintErrf("Capturing packets... Press Ctrl+C to stop.\n")
|
||||||
|
} else {
|
||||||
|
cmd.PrintErrf("Capturing packets (pcap)... Press Ctrl+C to stop.\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
streamErr := streamCapture(ctx, cmd, stream, out)
|
||||||
|
cleanupErr := cleanup()
|
||||||
|
if streamErr != nil {
|
||||||
|
return streamErr
|
||||||
|
}
|
||||||
|
return cleanupErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildCaptureRequest(cmd *cobra.Command, args []string) (*proto.StartCaptureRequest, error) {
|
||||||
|
req := &proto.StartCaptureRequest{}
|
||||||
|
|
||||||
|
if len(args) > 0 {
|
||||||
|
expr := strings.Join(args, " ")
|
||||||
|
if _, err := capture.ParseFilter(expr); err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid filter: %w", err)
|
||||||
|
}
|
||||||
|
req.FilterExpr = expr
|
||||||
|
}
|
||||||
|
|
||||||
|
if snap, _ := cmd.Flags().GetUint32("snap-len"); snap > 0 {
|
||||||
|
req.SnapLen = snap
|
||||||
|
}
|
||||||
|
if d, _ := cmd.Flags().GetDuration("duration"); d != 0 {
|
||||||
|
if d < 0 {
|
||||||
|
return nil, fmt.Errorf("duration must not be negative")
|
||||||
|
}
|
||||||
|
req.Duration = durationpb.New(d)
|
||||||
|
}
|
||||||
|
req.Verbose, _ = cmd.Flags().GetBool("verbose")
|
||||||
|
req.Ascii, _ = cmd.Flags().GetBool("ascii")
|
||||||
|
|
||||||
|
outPath, _ := cmd.Flags().GetString("output")
|
||||||
|
forcePcap, _ := cmd.Flags().GetBool("pcap")
|
||||||
|
req.TextOutput = !forcePcap && outPath == ""
|
||||||
|
|
||||||
|
return req, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func streamCapture(ctx context.Context, cmd *cobra.Command, stream proto.DaemonService_StartCaptureClient, out io.Writer) error {
|
||||||
|
for {
|
||||||
|
pkt, err := stream.Recv()
|
||||||
|
if err != nil {
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
cmd.PrintErrf("\nCapture stopped.\n")
|
||||||
|
return nil //nolint:nilerr // user interrupted
|
||||||
|
}
|
||||||
|
if err == io.EOF {
|
||||||
|
cmd.PrintErrf("\nCapture finished.\n")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return handleCaptureError(err)
|
||||||
|
}
|
||||||
|
if _, err := out.Write(pkt.GetData()); err != nil {
|
||||||
|
return fmt.Errorf("write output: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// captureOutput returns the writer for capture data and a cleanup function
|
||||||
|
// that finalizes the file. Errors from the cleanup must be propagated.
|
||||||
|
func captureOutput(cmd *cobra.Command) (io.Writer, func() error, error) {
|
||||||
|
outPath, _ := cmd.Flags().GetString("output")
|
||||||
|
if outPath == "" {
|
||||||
|
return os.Stdout, func() error { return nil }, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
f, err := os.CreateTemp(filepath.Dir(outPath), filepath.Base(outPath)+".*.tmp")
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("create output file: %w", err)
|
||||||
|
}
|
||||||
|
tmpPath := f.Name()
|
||||||
|
return f, func() error {
|
||||||
|
var merr *multierror.Error
|
||||||
|
if err := f.Close(); err != nil {
|
||||||
|
merr = multierror.Append(merr, fmt.Errorf("close output file: %w", err))
|
||||||
|
}
|
||||||
|
fi, statErr := os.Stat(tmpPath)
|
||||||
|
if statErr != nil || fi.Size() == 0 {
|
||||||
|
if rmErr := os.Remove(tmpPath); rmErr != nil && !os.IsNotExist(rmErr) {
|
||||||
|
merr = multierror.Append(merr, fmt.Errorf("remove empty output file: %w", rmErr))
|
||||||
|
}
|
||||||
|
return nberrors.FormatErrorOrNil(merr)
|
||||||
|
}
|
||||||
|
if err := os.Rename(tmpPath, outPath); err != nil {
|
||||||
|
merr = multierror.Append(merr, fmt.Errorf("rename output file: %w", err))
|
||||||
|
return nberrors.FormatErrorOrNil(merr)
|
||||||
|
}
|
||||||
|
cmd.PrintErrf("Wrote %s\n", outPath)
|
||||||
|
return nberrors.FormatErrorOrNil(merr)
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleCaptureError(err error) error {
|
||||||
|
if s, ok := status.FromError(err); ok {
|
||||||
|
return fmt.Errorf("%s", s.Message())
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
"google.golang.org/grpc/status"
|
"google.golang.org/grpc/status"
|
||||||
|
"google.golang.org/protobuf/types/known/durationpb"
|
||||||
|
|
||||||
"github.com/netbirdio/netbird/client/internal"
|
"github.com/netbirdio/netbird/client/internal"
|
||||||
"github.com/netbirdio/netbird/client/internal/debug"
|
"github.com/netbirdio/netbird/client/internal/debug"
|
||||||
@@ -16,7 +17,6 @@ import (
|
|||||||
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
||||||
"github.com/netbirdio/netbird/client/proto"
|
"github.com/netbirdio/netbird/client/proto"
|
||||||
"github.com/netbirdio/netbird/client/server"
|
"github.com/netbirdio/netbird/client/server"
|
||||||
nbstatus "github.com/netbirdio/netbird/client/status"
|
|
||||||
mgmProto "github.com/netbirdio/netbird/shared/management/proto"
|
mgmProto "github.com/netbirdio/netbird/shared/management/proto"
|
||||||
"github.com/netbirdio/netbird/upload-server/types"
|
"github.com/netbirdio/netbird/upload-server/types"
|
||||||
)
|
)
|
||||||
@@ -98,7 +98,6 @@ func debugBundle(cmd *cobra.Command, _ []string) error {
|
|||||||
client := proto.NewDaemonServiceClient(conn)
|
client := proto.NewDaemonServiceClient(conn)
|
||||||
request := &proto.DebugBundleRequest{
|
request := &proto.DebugBundleRequest{
|
||||||
Anonymize: anonymizeFlag,
|
Anonymize: anonymizeFlag,
|
||||||
Status: getStatusOutput(cmd, anonymizeFlag),
|
|
||||||
SystemInfo: systemInfoFlag,
|
SystemInfo: systemInfoFlag,
|
||||||
LogFileCount: logFileCount,
|
LogFileCount: logFileCount,
|
||||||
}
|
}
|
||||||
@@ -136,6 +135,7 @@ func setLogLevel(cmd *cobra.Command, args []string) error {
|
|||||||
client := proto.NewDaemonServiceClient(conn)
|
client := proto.NewDaemonServiceClient(conn)
|
||||||
level := server.ParseLogLevel(args[0])
|
level := server.ParseLogLevel(args[0])
|
||||||
if level == proto.LogLevel_UNKNOWN {
|
if level == proto.LogLevel_UNKNOWN {
|
||||||
|
//nolint
|
||||||
return fmt.Errorf("unknown log level: %s. Available levels are: panic, fatal, error, warn, info, debug, trace\n", args[0])
|
return fmt.Errorf("unknown log level: %s. Available levels are: panic, fatal, error, warn, info, debug, trace\n", args[0])
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -182,10 +182,11 @@ func runForDuration(cmd *cobra.Command, args []string) error {
|
|||||||
|
|
||||||
if stateWasDown {
|
if stateWasDown {
|
||||||
if _, err := client.Up(cmd.Context(), &proto.UpRequest{}); err != nil {
|
if _, err := client.Up(cmd.Context(), &proto.UpRequest{}); err != nil {
|
||||||
return fmt.Errorf("failed to up: %v", status.Convert(err).Message())
|
cmd.PrintErrf("Failed to bring service up: %v\n", status.Convert(err).Message())
|
||||||
|
} else {
|
||||||
|
cmd.Println("netbird up")
|
||||||
|
time.Sleep(time.Second * 10)
|
||||||
}
|
}
|
||||||
cmd.Println("netbird up")
|
|
||||||
time.Sleep(time.Second * 10)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
initialLevelTrace := initialLogLevel.GetLevel() >= proto.LogLevel_TRACE
|
initialLevelTrace := initialLogLevel.GetLevel() >= proto.LogLevel_TRACE
|
||||||
@@ -199,10 +200,13 @@ func runForDuration(cmd *cobra.Command, args []string) error {
|
|||||||
cmd.Println("Log level set to trace.")
|
cmd.Println("Log level set to trace.")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
needsRestoreUp := false
|
||||||
if _, err := client.Down(cmd.Context(), &proto.DownRequest{}); err != nil {
|
if _, err := client.Down(cmd.Context(), &proto.DownRequest{}); err != nil {
|
||||||
return fmt.Errorf("failed to down: %v", status.Convert(err).Message())
|
cmd.PrintErrf("Failed to bring service down: %v\n", status.Convert(err).Message())
|
||||||
|
} else {
|
||||||
|
needsRestoreUp = !stateWasDown
|
||||||
|
cmd.Println("netbird down")
|
||||||
}
|
}
|
||||||
cmd.Println("netbird down")
|
|
||||||
|
|
||||||
time.Sleep(1 * time.Second)
|
time.Sleep(1 * time.Second)
|
||||||
|
|
||||||
@@ -210,31 +214,88 @@ func runForDuration(cmd *cobra.Command, args []string) error {
|
|||||||
if _, err := client.SetSyncResponsePersistence(cmd.Context(), &proto.SetSyncResponsePersistenceRequest{
|
if _, err := client.SetSyncResponsePersistence(cmd.Context(), &proto.SetSyncResponsePersistenceRequest{
|
||||||
Enabled: true,
|
Enabled: true,
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
return fmt.Errorf("failed to enable sync response persistence: %v", status.Convert(err).Message())
|
cmd.PrintErrf("Failed to enable sync response persistence: %v\n", status.Convert(err).Message())
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, err := client.Up(cmd.Context(), &proto.UpRequest{}); err != nil {
|
if _, err := client.Up(cmd.Context(), &proto.UpRequest{}); err != nil {
|
||||||
return fmt.Errorf("failed to up: %v", status.Convert(err).Message())
|
cmd.PrintErrf("Failed to bring service up: %v\n", status.Convert(err).Message())
|
||||||
|
} else {
|
||||||
|
needsRestoreUp = false
|
||||||
|
cmd.Println("netbird up")
|
||||||
}
|
}
|
||||||
cmd.Println("netbird up")
|
|
||||||
|
|
||||||
time.Sleep(3 * time.Second)
|
time.Sleep(3 * time.Second)
|
||||||
|
|
||||||
headerPostUp := fmt.Sprintf("----- NetBird post-up - Timestamp: %s", time.Now().Format(time.RFC3339))
|
cpuProfilingStarted := false
|
||||||
statusOutput := fmt.Sprintf("%s\n%s", headerPostUp, getStatusOutput(cmd, anonymizeFlag))
|
if _, err := client.StartCPUProfile(cmd.Context(), &proto.StartCPUProfileRequest{}); err != nil {
|
||||||
|
cmd.PrintErrf("Failed to start CPU profiling: %v\n", err)
|
||||||
|
} else {
|
||||||
|
cpuProfilingStarted = true
|
||||||
|
defer func() {
|
||||||
|
if cpuProfilingStarted {
|
||||||
|
if _, err := client.StopCPUProfile(cmd.Context(), &proto.StopCPUProfileRequest{}); err != nil {
|
||||||
|
cmd.PrintErrf("Failed to stop CPU profiling: %v\n", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
captureStarted := false
|
||||||
|
if wantCapture, _ := cmd.Flags().GetBool("capture"); wantCapture {
|
||||||
|
captureTimeout := duration + 30*time.Second
|
||||||
|
const maxBundleCapture = 10 * time.Minute
|
||||||
|
if captureTimeout > maxBundleCapture {
|
||||||
|
captureTimeout = maxBundleCapture
|
||||||
|
}
|
||||||
|
_, err := client.StartBundleCapture(cmd.Context(), &proto.StartBundleCaptureRequest{
|
||||||
|
Timeout: durationpb.New(captureTimeout),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
cmd.PrintErrf("Failed to start packet capture: %v\n", status.Convert(err).Message())
|
||||||
|
} else {
|
||||||
|
captureStarted = true
|
||||||
|
cmd.Println("Packet capture started.")
|
||||||
|
// Safety: always stop on exit, even if the normal stop below runs too.
|
||||||
|
defer func() {
|
||||||
|
if captureStarted {
|
||||||
|
stopCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
if _, err := client.StopBundleCapture(stopCtx, &proto.StopBundleCaptureRequest{}); err != nil {
|
||||||
|
cmd.PrintErrf("Failed to stop packet capture: %v\n", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if waitErr := waitForDurationOrCancel(cmd.Context(), duration, cmd); waitErr != nil {
|
if waitErr := waitForDurationOrCancel(cmd.Context(), duration, cmd); waitErr != nil {
|
||||||
return waitErr
|
return waitErr
|
||||||
}
|
}
|
||||||
cmd.Println("\nDuration completed")
|
cmd.Println("\nDuration completed")
|
||||||
|
|
||||||
|
if captureStarted {
|
||||||
|
stopCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
if _, err := client.StopBundleCapture(stopCtx, &proto.StopBundleCaptureRequest{}); err != nil {
|
||||||
|
cmd.PrintErrf("Failed to stop packet capture: %v\n", err)
|
||||||
|
} else {
|
||||||
|
captureStarted = false
|
||||||
|
cmd.Println("Packet capture stopped.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if cpuProfilingStarted {
|
||||||
|
if _, err := client.StopCPUProfile(cmd.Context(), &proto.StopCPUProfileRequest{}); err != nil {
|
||||||
|
cmd.PrintErrf("Failed to stop CPU profiling: %v\n", err)
|
||||||
|
} else {
|
||||||
|
cpuProfilingStarted = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
cmd.Println("Creating debug bundle...")
|
cmd.Println("Creating debug bundle...")
|
||||||
|
|
||||||
headerPreDown := fmt.Sprintf("----- NetBird pre-down - Timestamp: %s - Duration: %s", time.Now().Format(time.RFC3339), duration)
|
|
||||||
statusOutput = fmt.Sprintf("%s\n%s\n%s", statusOutput, headerPreDown, getStatusOutput(cmd, anonymizeFlag))
|
|
||||||
request := &proto.DebugBundleRequest{
|
request := &proto.DebugBundleRequest{
|
||||||
Anonymize: anonymizeFlag,
|
Anonymize: anonymizeFlag,
|
||||||
Status: statusOutput,
|
|
||||||
SystemInfo: systemInfoFlag,
|
SystemInfo: systemInfoFlag,
|
||||||
LogFileCount: logFileCount,
|
LogFileCount: logFileCount,
|
||||||
}
|
}
|
||||||
@@ -246,18 +307,28 @@ func runForDuration(cmd *cobra.Command, args []string) error {
|
|||||||
return fmt.Errorf("failed to bundle debug: %v", status.Convert(err).Message())
|
return fmt.Errorf("failed to bundle debug: %v", status.Convert(err).Message())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if needsRestoreUp {
|
||||||
|
if _, err := client.Up(cmd.Context(), &proto.UpRequest{}); err != nil {
|
||||||
|
cmd.PrintErrf("Failed to restore service up state: %v\n", status.Convert(err).Message())
|
||||||
|
} else {
|
||||||
|
cmd.Println("netbird up (restored)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if stateWasDown {
|
if stateWasDown {
|
||||||
if _, err := client.Down(cmd.Context(), &proto.DownRequest{}); err != nil {
|
if _, err := client.Down(cmd.Context(), &proto.DownRequest{}); err != nil {
|
||||||
return fmt.Errorf("failed to down: %v", status.Convert(err).Message())
|
cmd.PrintErrf("Failed to restore service down state: %v\n", status.Convert(err).Message())
|
||||||
|
} else {
|
||||||
|
cmd.Println("netbird down")
|
||||||
}
|
}
|
||||||
cmd.Println("netbird down")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if !initialLevelTrace {
|
if !initialLevelTrace {
|
||||||
if _, err := client.SetLogLevel(cmd.Context(), &proto.SetLogLevelRequest{Level: initialLogLevel.GetLevel()}); err != nil {
|
if _, err := client.SetLogLevel(cmd.Context(), &proto.SetLogLevelRequest{Level: initialLogLevel.GetLevel()}); err != nil {
|
||||||
return fmt.Errorf("failed to restore log level: %v", status.Convert(err).Message())
|
cmd.PrintErrf("Failed to restore log level: %v\n", status.Convert(err).Message())
|
||||||
|
} else {
|
||||||
|
cmd.Println("Log level restored to", initialLogLevel.GetLevel())
|
||||||
}
|
}
|
||||||
cmd.Println("Log level restored to", initialLogLevel.GetLevel())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd.Printf("Local file:\n%s\n", resp.GetPath())
|
cmd.Printf("Local file:\n%s\n", resp.GetPath())
|
||||||
@@ -301,25 +372,6 @@ func setSyncResponsePersistence(cmd *cobra.Command, args []string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func getStatusOutput(cmd *cobra.Command, anon bool) string {
|
|
||||||
var statusOutputString string
|
|
||||||
statusResp, err := getStatus(cmd.Context(), true)
|
|
||||||
if err != nil {
|
|
||||||
cmd.PrintErrf("Failed to get status: %v\n", err)
|
|
||||||
} else {
|
|
||||||
pm := profilemanager.NewProfileManager()
|
|
||||||
var profName string
|
|
||||||
if activeProf, err := pm.GetActiveProfile(); err == nil {
|
|
||||||
profName = activeProf.Name
|
|
||||||
}
|
|
||||||
|
|
||||||
statusOutputString = nbstatus.ParseToFullDetailSummary(
|
|
||||||
nbstatus.ConvertToStatusOutputOverview(statusResp, anon, "", nil, nil, nil, "", profName),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return statusOutputString
|
|
||||||
}
|
|
||||||
|
|
||||||
func waitForDurationOrCancel(ctx context.Context, duration time.Duration, cmd *cobra.Command) error {
|
func waitForDurationOrCancel(ctx context.Context, duration time.Duration, cmd *cobra.Command) error {
|
||||||
ticker := time.NewTicker(1 * time.Second)
|
ticker := time.NewTicker(1 * time.Second)
|
||||||
defer ticker.Stop()
|
defer ticker.Stop()
|
||||||
@@ -378,7 +430,8 @@ func generateDebugBundle(config *profilemanager.Config, recorder *peer.Status, c
|
|||||||
InternalConfig: config,
|
InternalConfig: config,
|
||||||
StatusRecorder: recorder,
|
StatusRecorder: recorder,
|
||||||
SyncResponse: syncResponse,
|
SyncResponse: syncResponse,
|
||||||
LogFile: logFilePath,
|
LogPath: logFilePath,
|
||||||
|
CPUProfile: nil,
|
||||||
},
|
},
|
||||||
debug.BundleConfig{
|
debug.BundleConfig{
|
||||||
IncludeSystemInfo: true,
|
IncludeSystemInfo: true,
|
||||||
@@ -403,4 +456,5 @@ func init() {
|
|||||||
forCmd.Flags().BoolVarP(&systemInfoFlag, "system-info", "S", true, "Adds system information to the debug bundle")
|
forCmd.Flags().BoolVarP(&systemInfoFlag, "system-info", "S", true, "Adds system information to the debug bundle")
|
||||||
forCmd.Flags().BoolVarP(&uploadBundleFlag, "upload-bundle", "U", false, "Uploads the debug bundle to a server")
|
forCmd.Flags().BoolVarP(&uploadBundleFlag, "upload-bundle", "U", false, "Uploads the debug bundle to a server")
|
||||||
forCmd.Flags().StringVar(&uploadBundleURLFlag, "upload-bundle-url", types.DefaultBundleURL, "Service URL to get an URL to upload the debug bundle")
|
forCmd.Flags().StringVar(&uploadBundleURLFlag, "upload-bundle-url", types.DefaultBundleURL, "Service URL to get an URL to upload the debug bundle")
|
||||||
|
forCmd.Flags().Bool("capture", false, "Capture packets during the debug duration and include in bundle")
|
||||||
}
|
}
|
||||||
|
|||||||
287
client/cmd/expose.go
Normal file
287
client/cmd/expose.go
Normal file
@@ -0,0 +1,287 @@
|
|||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"syscall"
|
||||||
|
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
"google.golang.org/grpc/status"
|
||||||
|
|
||||||
|
"github.com/netbirdio/netbird/client/internal/expose"
|
||||||
|
"github.com/netbirdio/netbird/client/proto"
|
||||||
|
"github.com/netbirdio/netbird/util"
|
||||||
|
)
|
||||||
|
|
||||||
|
var pinRegexp = regexp.MustCompile(`^\d{6}$`)
|
||||||
|
|
||||||
|
var (
|
||||||
|
exposePin string
|
||||||
|
exposePassword string
|
||||||
|
exposeUserGroups []string
|
||||||
|
exposeDomain string
|
||||||
|
exposeNamePrefix string
|
||||||
|
exposeProtocol string
|
||||||
|
exposeExternalPort uint16
|
||||||
|
)
|
||||||
|
|
||||||
|
var exposeCmd = &cobra.Command{
|
||||||
|
Use: "expose <port>",
|
||||||
|
Short: "Expose a local port via the NetBird reverse proxy",
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
Example: ` netbird expose --with-password safe-pass 8080
|
||||||
|
netbird expose --protocol tcp 5432
|
||||||
|
netbird expose --protocol tcp --with-external-port 5433 5432
|
||||||
|
netbird expose --protocol tls --with-custom-domain tls.example.com 4443`,
|
||||||
|
RunE: exposeFn,
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
exposeCmd.Flags().StringVar(&exposePin, "with-pin", "", "Protect the exposed service with a 6-digit PIN (e.g. --with-pin 123456)")
|
||||||
|
exposeCmd.Flags().StringVar(&exposePassword, "with-password", "", "Protect the exposed service with a password (e.g. --with-password my-secret)")
|
||||||
|
exposeCmd.Flags().StringSliceVar(&exposeUserGroups, "with-user-groups", nil, "Restrict access to specific user groups with SSO (e.g. --with-user-groups devops,Backend)")
|
||||||
|
exposeCmd.Flags().StringVar(&exposeDomain, "with-custom-domain", "", "Custom domain for the exposed service, must be configured to your account (e.g. --with-custom-domain myapp.example.com)")
|
||||||
|
exposeCmd.Flags().StringVar(&exposeNamePrefix, "with-name-prefix", "", "Prefix for the generated service name (e.g. --with-name-prefix my-app)")
|
||||||
|
exposeCmd.Flags().StringVar(&exposeProtocol, "protocol", "http", "Protocol to use: http, https, tcp, udp, or tls (e.g. --protocol tcp)")
|
||||||
|
exposeCmd.Flags().Uint16Var(&exposeExternalPort, "with-external-port", 0, "Public-facing external port on the proxy cluster (defaults to the target port for L4)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// isClusterProtocol returns true for L4/TLS protocols that reject HTTP-style auth flags.
|
||||||
|
func isClusterProtocol(protocol string) bool {
|
||||||
|
switch strings.ToLower(protocol) {
|
||||||
|
case "tcp", "udp", "tls":
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// isPortBasedProtocol returns true for pure port-based protocols (TCP/UDP)
|
||||||
|
// where domain display doesn't apply. TLS uses SNI so it has a domain.
|
||||||
|
func isPortBasedProtocol(protocol string) bool {
|
||||||
|
switch strings.ToLower(protocol) {
|
||||||
|
case "tcp", "udp":
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractPort returns the port portion of a URL like "tcp://host:12345", or
|
||||||
|
// falls back to the given default formatted as a string.
|
||||||
|
func extractPort(serviceURL string, fallback uint16) string {
|
||||||
|
u := serviceURL
|
||||||
|
if idx := strings.Index(u, "://"); idx != -1 {
|
||||||
|
u = u[idx+3:]
|
||||||
|
}
|
||||||
|
if i := strings.LastIndex(u, ":"); i != -1 {
|
||||||
|
if p := u[i+1:]; p != "" {
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strconv.FormatUint(uint64(fallback), 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveExternalPort returns the effective external port, defaulting to the target port.
|
||||||
|
func resolveExternalPort(targetPort uint64) uint16 {
|
||||||
|
if exposeExternalPort != 0 {
|
||||||
|
return exposeExternalPort
|
||||||
|
}
|
||||||
|
return uint16(targetPort)
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateExposeFlags(cmd *cobra.Command, portStr string) (uint64, error) {
|
||||||
|
port, err := strconv.ParseUint(portStr, 10, 32)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("invalid port number: %s", portStr)
|
||||||
|
}
|
||||||
|
if port == 0 || port > 65535 {
|
||||||
|
return 0, fmt.Errorf("invalid port number: must be between 1 and 65535")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !isProtocolValid(exposeProtocol) {
|
||||||
|
return 0, fmt.Errorf("unsupported protocol %q: must be http, https, tcp, udp, or tls", exposeProtocol)
|
||||||
|
}
|
||||||
|
|
||||||
|
if isClusterProtocol(exposeProtocol) {
|
||||||
|
if exposePin != "" || exposePassword != "" || len(exposeUserGroups) > 0 {
|
||||||
|
return 0, fmt.Errorf("auth flags (--with-pin, --with-password, --with-user-groups) are not supported for %s protocol", exposeProtocol)
|
||||||
|
}
|
||||||
|
} else if cmd.Flags().Changed("with-external-port") {
|
||||||
|
return 0, fmt.Errorf("--with-external-port is not supported for %s protocol", exposeProtocol)
|
||||||
|
}
|
||||||
|
|
||||||
|
if exposePin != "" && !pinRegexp.MatchString(exposePin) {
|
||||||
|
return 0, fmt.Errorf("invalid pin: must be exactly 6 digits")
|
||||||
|
}
|
||||||
|
|
||||||
|
if cmd.Flags().Changed("with-password") && exposePassword == "" {
|
||||||
|
return 0, fmt.Errorf("password cannot be empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
if cmd.Flags().Changed("with-user-groups") && len(exposeUserGroups) == 0 {
|
||||||
|
return 0, fmt.Errorf("user groups cannot be empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
return port, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func isProtocolValid(exposeProtocol string) bool {
|
||||||
|
switch strings.ToLower(exposeProtocol) {
|
||||||
|
case "http", "https", "tcp", "udp", "tls":
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func exposeFn(cmd *cobra.Command, args []string) error {
|
||||||
|
SetFlagsFromEnvVars(rootCmd)
|
||||||
|
|
||||||
|
if err := util.InitLog(logLevel, util.LogConsole); err != nil {
|
||||||
|
log.Errorf("failed initializing log %v", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.Root().SilenceUsage = false
|
||||||
|
|
||||||
|
port, err := validateExposeFlags(cmd, args[0])
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.Root().SilenceUsage = true
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(cmd.Context())
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
sigCh := make(chan os.Signal, 1)
|
||||||
|
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
|
||||||
|
go func() {
|
||||||
|
<-sigCh
|
||||||
|
cancel()
|
||||||
|
}()
|
||||||
|
|
||||||
|
conn, err := DialClientGRPCServer(ctx, daemonAddr)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("connect to daemon: %w", err)
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
if err := conn.Close(); err != nil {
|
||||||
|
log.Debugf("failed to close daemon connection: %v", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
client := proto.NewDaemonServiceClient(conn)
|
||||||
|
|
||||||
|
protocol, err := toExposeProtocol(exposeProtocol)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
req := &proto.ExposeServiceRequest{
|
||||||
|
Port: uint32(port),
|
||||||
|
Protocol: protocol,
|
||||||
|
Pin: exposePin,
|
||||||
|
Password: exposePassword,
|
||||||
|
UserGroups: exposeUserGroups,
|
||||||
|
Domain: exposeDomain,
|
||||||
|
NamePrefix: exposeNamePrefix,
|
||||||
|
}
|
||||||
|
if isClusterProtocol(exposeProtocol) {
|
||||||
|
req.ListenPort = uint32(resolveExternalPort(port))
|
||||||
|
}
|
||||||
|
|
||||||
|
stream, err := client.ExposeService(ctx, req)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("expose service: %v", status.Convert(err).Message())
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := handleExposeReady(cmd, stream, port); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return waitForExposeEvents(cmd, ctx, stream)
|
||||||
|
}
|
||||||
|
|
||||||
|
func toExposeProtocol(exposeProtocol string) (proto.ExposeProtocol, error) {
|
||||||
|
p, err := expose.ParseProtocolType(exposeProtocol)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("invalid protocol: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
switch p {
|
||||||
|
case expose.ProtocolHTTP:
|
||||||
|
return proto.ExposeProtocol_EXPOSE_HTTP, nil
|
||||||
|
case expose.ProtocolHTTPS:
|
||||||
|
return proto.ExposeProtocol_EXPOSE_HTTPS, nil
|
||||||
|
case expose.ProtocolTCP:
|
||||||
|
return proto.ExposeProtocol_EXPOSE_TCP, nil
|
||||||
|
case expose.ProtocolUDP:
|
||||||
|
return proto.ExposeProtocol_EXPOSE_UDP, nil
|
||||||
|
case expose.ProtocolTLS:
|
||||||
|
return proto.ExposeProtocol_EXPOSE_TLS, nil
|
||||||
|
default:
|
||||||
|
return 0, fmt.Errorf("unhandled protocol type: %d", p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleExposeReady(cmd *cobra.Command, stream proto.DaemonService_ExposeServiceClient, port uint64) error {
|
||||||
|
event, err := stream.Recv()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("receive expose event: %v", status.Convert(err).Message())
|
||||||
|
}
|
||||||
|
|
||||||
|
ready, ok := event.Event.(*proto.ExposeServiceEvent_Ready)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("unexpected expose event: %T", event.Event)
|
||||||
|
}
|
||||||
|
printExposeReady(cmd, ready.Ready, port)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func printExposeReady(cmd *cobra.Command, r *proto.ExposeServiceReady, port uint64) {
|
||||||
|
cmd.Println("Service exposed successfully!")
|
||||||
|
cmd.Printf(" Name: %s\n", r.ServiceName)
|
||||||
|
if r.ServiceUrl != "" {
|
||||||
|
cmd.Printf(" URL: %s\n", r.ServiceUrl)
|
||||||
|
}
|
||||||
|
if r.Domain != "" && !isPortBasedProtocol(exposeProtocol) {
|
||||||
|
cmd.Printf(" Domain: %s\n", r.Domain)
|
||||||
|
}
|
||||||
|
cmd.Printf(" Protocol: %s\n", exposeProtocol)
|
||||||
|
cmd.Printf(" Internal: %d\n", port)
|
||||||
|
if isClusterProtocol(exposeProtocol) {
|
||||||
|
cmd.Printf(" External: %s\n", extractPort(r.ServiceUrl, resolveExternalPort(port)))
|
||||||
|
}
|
||||||
|
if r.PortAutoAssigned && exposeExternalPort != 0 {
|
||||||
|
cmd.Printf("\n Note: requested port %d was reassigned\n", exposeExternalPort)
|
||||||
|
}
|
||||||
|
cmd.Println()
|
||||||
|
cmd.Println("Press Ctrl+C to stop exposing.")
|
||||||
|
}
|
||||||
|
|
||||||
|
func waitForExposeEvents(cmd *cobra.Command, ctx context.Context, stream proto.DaemonService_ExposeServiceClient) error {
|
||||||
|
for {
|
||||||
|
_, err := stream.Recv()
|
||||||
|
if err != nil {
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
cmd.Println("\nService stopped.")
|
||||||
|
//nolint:nilerr
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if errors.Is(err, io.EOF) {
|
||||||
|
return fmt.Errorf("connection to daemon closed unexpectedly")
|
||||||
|
}
|
||||||
|
return fmt.Errorf("stream error: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,15 +4,13 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
|
||||||
"os/user"
|
"os/user"
|
||||||
"runtime"
|
"runtime"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
|
||||||
|
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
"github.com/skratchdot/open-golang/open"
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
"golang.org/x/term"
|
||||||
"google.golang.org/grpc/codes"
|
"google.golang.org/grpc/codes"
|
||||||
gstatus "google.golang.org/grpc/status"
|
gstatus "google.golang.org/grpc/status"
|
||||||
|
|
||||||
@@ -26,6 +24,7 @@ import (
|
|||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
loginCmd.PersistentFlags().BoolVar(&noBrowser, noBrowserFlag, false, noBrowserDesc)
|
loginCmd.PersistentFlags().BoolVar(&noBrowser, noBrowserFlag, false, noBrowserDesc)
|
||||||
|
loginCmd.PersistentFlags().BoolVar(&showQR, showQRFlag, false, showQRDesc)
|
||||||
loginCmd.PersistentFlags().StringVar(&profileName, profileNameFlag, "", profileNameDesc)
|
loginCmd.PersistentFlags().StringVar(&profileName, profileNameFlag, "", profileNameDesc)
|
||||||
loginCmd.PersistentFlags().StringVarP(&configPath, "config", "c", "", "(DEPRECATED) Netbird config file location")
|
loginCmd.PersistentFlags().StringVarP(&configPath, "config", "c", "", "(DEPRECATED) Netbird config file location")
|
||||||
}
|
}
|
||||||
@@ -83,6 +82,7 @@ var loginCmd = &cobra.Command{
|
|||||||
func doDaemonLogin(ctx context.Context, cmd *cobra.Command, providedSetupKey string, activeProf *profilemanager.Profile, username string, pm *profilemanager.ProfileManager) error {
|
func doDaemonLogin(ctx context.Context, cmd *cobra.Command, providedSetupKey string, activeProf *profilemanager.Profile, username string, pm *profilemanager.ProfileManager) error {
|
||||||
conn, err := DialClientGRPCServer(ctx, daemonAddr)
|
conn, err := DialClientGRPCServer(ctx, daemonAddr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
//nolint
|
||||||
return fmt.Errorf("failed to connect to daemon error: %v\n"+
|
return fmt.Errorf("failed to connect to daemon error: %v\n"+
|
||||||
"If the daemon is not running please run: "+
|
"If the daemon is not running please run: "+
|
||||||
"\nnetbird service install \nnetbird service start\n", err)
|
"\nnetbird service install \nnetbird service start\n", err)
|
||||||
@@ -106,6 +106,13 @@ func doDaemonLogin(ctx context.Context, cmd *cobra.Command, providedSetupKey str
|
|||||||
Username: &username,
|
Username: &username,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
profileState, err := pm.GetProfileState(activeProf.Name)
|
||||||
|
if err != nil {
|
||||||
|
log.Debugf("failed to get profile state for login hint: %v", err)
|
||||||
|
} else if profileState.Email != "" {
|
||||||
|
loginRequest.Hint = &profileState.Email
|
||||||
|
}
|
||||||
|
|
||||||
if rootCmd.PersistentFlags().Changed(preSharedKeyFlag) {
|
if rootCmd.PersistentFlags().Changed(preSharedKeyFlag) {
|
||||||
loginRequest.OptionalPreSharedKey = &preSharedKey
|
loginRequest.OptionalPreSharedKey = &preSharedKey
|
||||||
}
|
}
|
||||||
@@ -201,6 +208,7 @@ func switchProfileOnDaemon(ctx context.Context, pm *profilemanager.ProfileManage
|
|||||||
func switchProfile(ctx context.Context, profileName string, username string) error {
|
func switchProfile(ctx context.Context, profileName string, username string) error {
|
||||||
conn, err := DialClientGRPCServer(ctx, daemonAddr)
|
conn, err := DialClientGRPCServer(ctx, daemonAddr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
//nolint
|
||||||
return fmt.Errorf("failed to connect to daemon error: %v\n"+
|
return fmt.Errorf("failed to connect to daemon error: %v\n"+
|
||||||
"If the daemon is not running please run: "+
|
"If the daemon is not running please run: "+
|
||||||
"\nnetbird service install \nnetbird service start\n", err)
|
"\nnetbird service install \nnetbird service start\n", err)
|
||||||
@@ -241,7 +249,7 @@ func doForegroundLogin(ctx context.Context, cmd *cobra.Command, setupKey string,
|
|||||||
return fmt.Errorf("read config file %s: %v", configFilePath, err)
|
return fmt.Errorf("read config file %s: %v", configFilePath, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = foregroundLogin(ctx, cmd, config, setupKey)
|
err = foregroundLogin(ctx, cmd, config, setupKey, activeProf.Name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("foreground login failed: %v", err)
|
return fmt.Errorf("foreground login failed: %v", err)
|
||||||
}
|
}
|
||||||
@@ -250,7 +258,7 @@ func doForegroundLogin(ctx context.Context, cmd *cobra.Command, setupKey string,
|
|||||||
}
|
}
|
||||||
|
|
||||||
func handleSSOLogin(ctx context.Context, cmd *cobra.Command, loginResp *proto.LoginResponse, client proto.DaemonServiceClient, pm *profilemanager.ProfileManager) error {
|
func handleSSOLogin(ctx context.Context, cmd *cobra.Command, loginResp *proto.LoginResponse, client proto.DaemonServiceClient, pm *profilemanager.ProfileManager) error {
|
||||||
openURL(cmd, loginResp.VerificationURIComplete, loginResp.UserCode, noBrowser)
|
openURL(cmd, loginResp.VerificationURIComplete, loginResp.UserCode, noBrowser, showQR)
|
||||||
|
|
||||||
resp, err := client.WaitSSOLogin(ctx, &proto.WaitSSOLoginRequest{UserCode: loginResp.UserCode, Hostname: hostName})
|
resp, err := client.WaitSSOLogin(ctx, &proto.WaitSSOLoginRequest{UserCode: loginResp.UserCode, Hostname: hostName})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -269,54 +277,46 @@ func handleSSOLogin(ctx context.Context, cmd *cobra.Command, loginResp *proto.Lo
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func foregroundLogin(ctx context.Context, cmd *cobra.Command, config *profilemanager.Config, setupKey string) error {
|
func foregroundLogin(ctx context.Context, cmd *cobra.Command, config *profilemanager.Config, setupKey, profileName string) error {
|
||||||
needsLogin := false
|
authClient, err := auth.NewAuth(ctx, config.PrivateKey, config.ManagementURL, config)
|
||||||
|
|
||||||
err := WithBackOff(func() error {
|
|
||||||
err := internal.Login(ctx, config, "", "")
|
|
||||||
if s, ok := gstatus.FromError(err); ok && (s.Code() == codes.InvalidArgument || s.Code() == codes.PermissionDenied) {
|
|
||||||
needsLogin = true
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return err
|
|
||||||
})
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("backoff cycle failed: %v", err)
|
return fmt.Errorf("failed to create auth client: %v", err)
|
||||||
|
}
|
||||||
|
defer authClient.Close()
|
||||||
|
|
||||||
|
needsLogin, err := authClient.IsLoginRequired(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("check login required: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
jwtToken := ""
|
jwtToken := ""
|
||||||
if setupKey == "" && needsLogin {
|
if setupKey == "" && needsLogin {
|
||||||
tokenInfo, err := foregroundGetTokenInfo(ctx, cmd, config)
|
tokenInfo, err := foregroundGetTokenInfo(ctx, cmd, config, profileName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("interactive sso login failed: %v", err)
|
return fmt.Errorf("interactive sso login failed: %v", err)
|
||||||
}
|
}
|
||||||
jwtToken = tokenInfo.GetTokenToUse()
|
jwtToken = tokenInfo.GetTokenToUse()
|
||||||
}
|
}
|
||||||
|
|
||||||
var lastError error
|
err, _ = authClient.Login(ctx, setupKey, jwtToken)
|
||||||
|
|
||||||
err = WithBackOff(func() error {
|
|
||||||
err := internal.Login(ctx, config, setupKey, jwtToken)
|
|
||||||
if s, ok := gstatus.FromError(err); ok && (s.Code() == codes.InvalidArgument || s.Code() == codes.PermissionDenied) {
|
|
||||||
lastError = err
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return err
|
|
||||||
})
|
|
||||||
|
|
||||||
if lastError != nil {
|
|
||||||
return fmt.Errorf("login failed: %v", lastError)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("backoff cycle failed: %v", err)
|
return fmt.Errorf("login failed: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func foregroundGetTokenInfo(ctx context.Context, cmd *cobra.Command, config *profilemanager.Config) (*auth.TokenInfo, error) {
|
func foregroundGetTokenInfo(ctx context.Context, cmd *cobra.Command, config *profilemanager.Config, profileName string) (*auth.TokenInfo, error) {
|
||||||
oAuthFlow, err := auth.NewOAuthFlow(ctx, config, isUnixRunningDesktop())
|
hint := ""
|
||||||
|
pm := profilemanager.NewProfileManager()
|
||||||
|
profileState, err := pm.GetProfileState(profileName)
|
||||||
|
if err != nil {
|
||||||
|
log.Debugf("failed to get profile state for login hint: %v", err)
|
||||||
|
} else if profileState.Email != "" {
|
||||||
|
hint = profileState.Email
|
||||||
|
}
|
||||||
|
|
||||||
|
oAuthFlow, err := auth.NewOAuthFlow(ctx, config, isUnixRunningDesktop(), false, hint)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -326,13 +326,9 @@ func foregroundGetTokenInfo(ctx context.Context, cmd *cobra.Command, config *pro
|
|||||||
return nil, fmt.Errorf("getting a request OAuth flow info failed: %v", err)
|
return nil, fmt.Errorf("getting a request OAuth flow info failed: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
openURL(cmd, flowInfo.VerificationURIComplete, flowInfo.UserCode, noBrowser)
|
openURL(cmd, flowInfo.VerificationURIComplete, flowInfo.UserCode, noBrowser, showQR)
|
||||||
|
|
||||||
waitTimeout := time.Duration(flowInfo.ExpiresIn) * time.Second
|
tokenInfo, err := oAuthFlow.WaitToken(context.TODO(), flowInfo)
|
||||||
waitCTX, c := context.WithTimeout(context.TODO(), waitTimeout)
|
|
||||||
defer c()
|
|
||||||
|
|
||||||
tokenInfo, err := oAuthFlow.WaitToken(waitCTX, flowInfo)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("waiting for browser login failed: %v", err)
|
return nil, fmt.Errorf("waiting for browser login failed: %v", err)
|
||||||
}
|
}
|
||||||
@@ -340,7 +336,7 @@ func foregroundGetTokenInfo(ctx context.Context, cmd *cobra.Command, config *pro
|
|||||||
return &tokenInfo, nil
|
return &tokenInfo, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func openURL(cmd *cobra.Command, verificationURIComplete, userCode string, noBrowser bool) {
|
func openURL(cmd *cobra.Command, verificationURIComplete, userCode string, noBrowser, showQR bool) {
|
||||||
var codeMsg string
|
var codeMsg string
|
||||||
if userCode != "" && !strings.Contains(verificationURIComplete, userCode) {
|
if userCode != "" && !strings.Contains(verificationURIComplete, userCode) {
|
||||||
codeMsg = fmt.Sprintf("and enter the code %s to authenticate.", userCode)
|
codeMsg = fmt.Sprintf("and enter the code %s to authenticate.", userCode)
|
||||||
@@ -354,24 +350,22 @@ func openURL(cmd *cobra.Command, verificationURIComplete, userCode string, noBro
|
|||||||
verificationURIComplete + " " + codeMsg)
|
verificationURIComplete + " " + codeMsg)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if showQR {
|
||||||
|
if f, ok := cmd.OutOrStdout().(*os.File); ok && term.IsTerminal(int(f.Fd())) {
|
||||||
|
printQRCode(f, verificationURIComplete)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
cmd.Println("")
|
cmd.Println("")
|
||||||
|
|
||||||
if !noBrowser {
|
if !noBrowser {
|
||||||
if err := openBrowser(verificationURIComplete); err != nil {
|
if err := util.OpenBrowser(verificationURIComplete); err != nil {
|
||||||
cmd.Println("\nAlternatively, you may want to use a setup key, see:\n\n" +
|
cmd.Println("\nAlternatively, you may want to use a setup key, see:\n\n" +
|
||||||
"https://docs.netbird.io/how-to/register-machines-using-setup-keys")
|
"https://docs.netbird.io/how-to/register-machines-using-setup-keys")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// openBrowser opens the URL in a browser, respecting the BROWSER environment variable.
|
|
||||||
func openBrowser(url string) error {
|
|
||||||
if browser := os.Getenv("BROWSER"); browser != "" {
|
|
||||||
return exec.Command(browser, url).Start()
|
|
||||||
}
|
|
||||||
return open.Run(url)
|
|
||||||
}
|
|
||||||
|
|
||||||
// isUnixRunningDesktop checks if a Linux OS is running desktop environment
|
// isUnixRunningDesktop checks if a Linux OS is running desktop environment
|
||||||
func isUnixRunningDesktop() bool {
|
func isUnixRunningDesktop() bool {
|
||||||
if runtime.GOOS != "linux" && runtime.GOOS != "freebsd" {
|
if runtime.GOOS != "linux" && runtime.GOOS != "freebsd" {
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
//go:build pprof
|
//go:build pprof
|
||||||
// +build pprof
|
|
||||||
|
|
||||||
package cmd
|
package cmd
|
||||||
|
|
||||||
|
|||||||
25
client/cmd/qr.go
Normal file
25
client/cmd/qr.go
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
|
||||||
|
"github.com/mdp/qrterminal/v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
// printQRCode prints a QR code for the given URL to the writer.
|
||||||
|
// Called only when the user explicitly requests QR output via --qr.
|
||||||
|
func printQRCode(w io.Writer, url string) {
|
||||||
|
if url == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
qrterminal.GenerateWithConfig(url, qrterminal.Config{
|
||||||
|
Level: qrterminal.M,
|
||||||
|
Writer: w,
|
||||||
|
HalfBlocks: true,
|
||||||
|
BlackChar: qrterminal.BLACK_BLACK,
|
||||||
|
WhiteChar: qrterminal.WHITE_WHITE,
|
||||||
|
BlackWhiteChar: qrterminal.BLACK_WHITE,
|
||||||
|
WhiteBlackChar: qrterminal.WHITE_BLACK,
|
||||||
|
QuietZone: qrterminal.QUIET_ZONE,
|
||||||
|
})
|
||||||
|
}
|
||||||
26
client/cmd/qr_test.go
Normal file
26
client/cmd/qr_test.go
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPrintQRCode_EmptyURL(t *testing.T) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
|
||||||
|
printQRCode(&buf, "")
|
||||||
|
|
||||||
|
if buf.Len() != 0 {
|
||||||
|
t.Error("expected no output for empty URL")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPrintQRCode_WritesOutput(t *testing.T) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
|
||||||
|
printQRCode(&buf, "https://example.com/auth")
|
||||||
|
|
||||||
|
if buf.Len() == 0 {
|
||||||
|
t.Error("expected QR code output for non-empty URL")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,6 +22,7 @@ import (
|
|||||||
"google.golang.org/grpc"
|
"google.golang.org/grpc"
|
||||||
"google.golang.org/grpc/credentials/insecure"
|
"google.golang.org/grpc/credentials/insecure"
|
||||||
|
|
||||||
|
daddr "github.com/netbirdio/netbird/client/internal/daemonaddr"
|
||||||
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -74,17 +75,31 @@ var (
|
|||||||
mtu uint16
|
mtu uint16
|
||||||
profilesDisabled bool
|
profilesDisabled bool
|
||||||
updateSettingsDisabled bool
|
updateSettingsDisabled bool
|
||||||
|
captureEnabled bool
|
||||||
|
networksDisabled bool
|
||||||
|
|
||||||
rootCmd = &cobra.Command{
|
rootCmd = &cobra.Command{
|
||||||
Use: "netbird",
|
Use: "netbird",
|
||||||
Short: "",
|
Short: "",
|
||||||
Long: "",
|
Long: "",
|
||||||
SilenceUsage: true,
|
SilenceUsage: true,
|
||||||
|
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
SetFlagsFromEnvVars(cmd.Root())
|
||||||
|
|
||||||
|
// Don't resolve for service commands — they create the socket, not connect to it.
|
||||||
|
if !isServiceCmd(cmd) {
|
||||||
|
daemonAddr = daddr.ResolveUnixDaemonAddr(daemonAddr)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
// Execute executes the root command.
|
// Execute executes the root command.
|
||||||
func Execute() error {
|
func Execute() error {
|
||||||
|
if isUpdateBinary() {
|
||||||
|
return updateCmd.Execute()
|
||||||
|
}
|
||||||
return rootCmd.Execute()
|
return rootCmd.Execute()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -141,6 +156,7 @@ func init() {
|
|||||||
rootCmd.AddCommand(forwardingRulesCmd)
|
rootCmd.AddCommand(forwardingRulesCmd)
|
||||||
rootCmd.AddCommand(debugCmd)
|
rootCmd.AddCommand(debugCmd)
|
||||||
rootCmd.AddCommand(profileCmd)
|
rootCmd.AddCommand(profileCmd)
|
||||||
|
rootCmd.AddCommand(exposeCmd)
|
||||||
|
|
||||||
networksCMD.AddCommand(routesListCmd)
|
networksCMD.AddCommand(routesListCmd)
|
||||||
networksCMD.AddCommand(routesSelectCmd, routesDeselectCmd)
|
networksCMD.AddCommand(routesSelectCmd, routesDeselectCmd)
|
||||||
@@ -382,11 +398,11 @@ func migrateToNetbird(oldPath, newPath string) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func getClient(cmd *cobra.Command) (*grpc.ClientConn, error) {
|
func getClient(cmd *cobra.Command) (*grpc.ClientConn, error) {
|
||||||
SetFlagsFromEnvVars(rootCmd)
|
|
||||||
cmd.SetOut(cmd.OutOrStdout())
|
cmd.SetOut(cmd.OutOrStdout())
|
||||||
|
|
||||||
conn, err := DialClientGRPCServer(cmd.Context(), daemonAddr)
|
conn, err := DialClientGRPCServer(cmd.Context(), daemonAddr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
//nolint
|
||||||
return nil, fmt.Errorf("failed to connect to daemon error: %v\n"+
|
return nil, fmt.Errorf("failed to connect to daemon error: %v\n"+
|
||||||
"If the daemon is not running please run: "+
|
"If the daemon is not running please run: "+
|
||||||
"\nnetbird service install \nnetbird service start\n", err)
|
"\nnetbird service install \nnetbird service start\n", err)
|
||||||
@@ -394,3 +410,13 @@ func getClient(cmd *cobra.Command) (*grpc.ClientConn, error) {
|
|||||||
|
|
||||||
return conn, nil
|
return conn, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// isServiceCmd returns true if cmd is the "service" command or a child of it.
|
||||||
|
func isServiceCmd(cmd *cobra.Command) bool {
|
||||||
|
for c := cmd; c != nil; c = c.Parent() {
|
||||||
|
if c.Name() == "service" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|||||||
@@ -41,13 +41,17 @@ func init() {
|
|||||||
defaultServiceName = "Netbird"
|
defaultServiceName = "Netbird"
|
||||||
}
|
}
|
||||||
|
|
||||||
serviceCmd.AddCommand(runCmd, startCmd, stopCmd, restartCmd, svcStatusCmd, installCmd, uninstallCmd, reconfigureCmd)
|
serviceCmd.AddCommand(runCmd, startCmd, stopCmd, restartCmd, svcStatusCmd, installCmd, uninstallCmd, reconfigureCmd, resetParamsCmd)
|
||||||
serviceCmd.PersistentFlags().BoolVar(&profilesDisabled, "disable-profiles", false, "Disables profiles feature. If enabled, the client will not be able to change or edit any profile. To persist this setting, use: netbird service install --disable-profiles")
|
serviceCmd.PersistentFlags().BoolVar(&profilesDisabled, "disable-profiles", false, "Disables profiles feature. If enabled, the client will not be able to change or edit any profile. To persist this setting, use: netbird service install --disable-profiles")
|
||||||
serviceCmd.PersistentFlags().BoolVar(&updateSettingsDisabled, "disable-update-settings", false, "Disables update settings feature. If enabled, the client will not be able to change or edit any settings. To persist this setting, use: netbird service install --disable-update-settings")
|
serviceCmd.PersistentFlags().BoolVar(&updateSettingsDisabled, "disable-update-settings", false, "Disables update settings feature. If enabled, the client will not be able to change or edit any settings. To persist this setting, use: netbird service install --disable-update-settings")
|
||||||
|
serviceCmd.PersistentFlags().BoolVar(&captureEnabled, "enable-capture", false, "Enables packet capture via 'netbird debug capture'. To persist, use: netbird service install --enable-capture")
|
||||||
|
serviceCmd.PersistentFlags().BoolVar(&networksDisabled, "disable-networks", false, "Disables network selection. If enabled, the client will not allow listing, selecting, or deselecting networks. To persist, use: netbird service install --disable-networks")
|
||||||
|
|
||||||
rootCmd.PersistentFlags().StringVarP(&serviceName, "service", "s", defaultServiceName, "Netbird system service name")
|
rootCmd.PersistentFlags().StringVarP(&serviceName, "service", "s", defaultServiceName, "Netbird system service name")
|
||||||
serviceEnvDesc := `Sets extra environment variables for the service. ` +
|
serviceEnvDesc := `Sets extra environment variables for the service. ` +
|
||||||
`You can specify a comma-separated list of KEY=VALUE pairs. ` +
|
`You can specify a comma-separated list of KEY=VALUE pairs. ` +
|
||||||
|
`New keys are merged with previously saved env vars; existing keys are overwritten. ` +
|
||||||
|
`Use --service-env "" to clear all saved env vars. ` +
|
||||||
`E.g. --service-env NB_LOG_LEVEL=debug,CUSTOM_VAR=value`
|
`E.g. --service-env NB_LOG_LEVEL=debug,CUSTOM_VAR=value`
|
||||||
|
|
||||||
installCmd.Flags().StringSliceVar(&serviceEnvVars, "service-env", nil, serviceEnvDesc)
|
installCmd.Flags().StringSliceVar(&serviceEnvVars, "service-env", nil, serviceEnvDesc)
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ func (p *program) Start(svc service.Service) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
serverInstance := server.New(p.ctx, util.FindFirstLogPath(logFiles), configPath, profilesDisabled, updateSettingsDisabled)
|
serverInstance := server.New(p.ctx, util.FindFirstLogPath(logFiles), configPath, profilesDisabled, updateSettingsDisabled, captureEnabled, networksDisabled)
|
||||||
if err := serverInstance.Start(); err != nil {
|
if err := serverInstance.Start(); err != nil {
|
||||||
log.Fatalf("failed to start daemon: %v", err)
|
log.Fatalf("failed to start daemon: %v", err)
|
||||||
}
|
}
|
||||||
@@ -103,7 +103,7 @@ func (p *program) Stop(srv service.Service) error {
|
|||||||
|
|
||||||
// Common setup for service control commands
|
// Common setup for service control commands
|
||||||
func setupServiceControlCommand(cmd *cobra.Command, ctx context.Context, cancel context.CancelFunc) (service.Service, error) {
|
func setupServiceControlCommand(cmd *cobra.Command, ctx context.Context, cancel context.CancelFunc) (service.Service, error) {
|
||||||
SetFlagsFromEnvVars(rootCmd)
|
// rootCmd env vars are already applied by PersistentPreRunE.
|
||||||
SetFlagsFromEnvVars(serviceCmd)
|
SetFlagsFromEnvVars(serviceCmd)
|
||||||
|
|
||||||
cmd.SetOut(cmd.OutOrStdout())
|
cmd.SetOut(cmd.OutOrStdout())
|
||||||
|
|||||||
@@ -59,6 +59,14 @@ func buildServiceArguments() []string {
|
|||||||
args = append(args, "--disable-update-settings")
|
args = append(args, "--disable-update-settings")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if captureEnabled {
|
||||||
|
args = append(args, "--enable-capture")
|
||||||
|
}
|
||||||
|
|
||||||
|
if networksDisabled {
|
||||||
|
args = append(args, "--disable-networks")
|
||||||
|
}
|
||||||
|
|
||||||
return args
|
return args
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -119,6 +127,10 @@ var installCmd = &cobra.Command{
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err := loadAndApplyServiceParams(cmd); err != nil {
|
||||||
|
cmd.PrintErrf("Warning: failed to load saved service params: %v\n", err)
|
||||||
|
}
|
||||||
|
|
||||||
svcConfig, err := createServiceConfigForInstall()
|
svcConfig, err := createServiceConfigForInstall()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -136,6 +148,10 @@ var installCmd = &cobra.Command{
|
|||||||
return fmt.Errorf("install service: %w", err)
|
return fmt.Errorf("install service: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err := saveServiceParams(currentServiceParams()); err != nil {
|
||||||
|
cmd.PrintErrf("Warning: failed to save service params: %v\n", err)
|
||||||
|
}
|
||||||
|
|
||||||
cmd.Println("NetBird service has been installed")
|
cmd.Println("NetBird service has been installed")
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
@@ -187,6 +203,10 @@ This command will temporarily stop the service, update its configuration, and re
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err := loadAndApplyServiceParams(cmd); err != nil {
|
||||||
|
cmd.PrintErrf("Warning: failed to load saved service params: %v\n", err)
|
||||||
|
}
|
||||||
|
|
||||||
wasRunning, err := isServiceRunning()
|
wasRunning, err := isServiceRunning()
|
||||||
if err != nil && !errors.Is(err, ErrGetServiceStatus) {
|
if err != nil && !errors.Is(err, ErrGetServiceStatus) {
|
||||||
return fmt.Errorf("check service status: %w", err)
|
return fmt.Errorf("check service status: %w", err)
|
||||||
@@ -222,6 +242,10 @@ This command will temporarily stop the service, update its configuration, and re
|
|||||||
return fmt.Errorf("install service with new config: %w", err)
|
return fmt.Errorf("install service with new config: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err := saveServiceParams(currentServiceParams()); err != nil {
|
||||||
|
cmd.PrintErrf("Warning: failed to save service params: %v\n", err)
|
||||||
|
}
|
||||||
|
|
||||||
if wasRunning {
|
if wasRunning {
|
||||||
cmd.Println("Starting NetBird service...")
|
cmd.Println("Starting NetBird service...")
|
||||||
if err := s.Start(); err != nil {
|
if err := s.Start(); err != nil {
|
||||||
@@ -259,6 +283,7 @@ func isServiceRunning() (bool, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
networkdConf = "/etc/systemd/networkd.conf"
|
||||||
networkdConfDir = "/etc/systemd/networkd.conf.d"
|
networkdConfDir = "/etc/systemd/networkd.conf.d"
|
||||||
networkdConfFile = "/etc/systemd/networkd.conf.d/99-netbird.conf"
|
networkdConfFile = "/etc/systemd/networkd.conf.d/99-netbird.conf"
|
||||||
networkdConfContent = `# Created by NetBird to prevent systemd-networkd from removing
|
networkdConfContent = `# Created by NetBird to prevent systemd-networkd from removing
|
||||||
@@ -273,12 +298,16 @@ ManageForeignRoutingPolicyRules=no
|
|||||||
// configureSystemdNetworkd creates a drop-in configuration file to prevent
|
// configureSystemdNetworkd creates a drop-in configuration file to prevent
|
||||||
// systemd-networkd from removing NetBird's routes and policy rules.
|
// systemd-networkd from removing NetBird's routes and policy rules.
|
||||||
func configureSystemdNetworkd() error {
|
func configureSystemdNetworkd() error {
|
||||||
parentDir := filepath.Dir(networkdConfDir)
|
if _, err := os.Stat(networkdConf); os.IsNotExist(err) {
|
||||||
if _, err := os.Stat(parentDir); os.IsNotExist(err) {
|
log.Debug("systemd-networkd not in use, skipping configuration")
|
||||||
log.Debug("systemd networkd.conf.d parent directory does not exist, skipping configuration")
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// nolint:gosec // standard networkd permissions
|
||||||
|
if err := os.MkdirAll(networkdConfDir, 0755); err != nil {
|
||||||
|
return fmt.Errorf("create networkd.conf.d directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
// nolint:gosec // standard networkd permissions
|
// nolint:gosec // standard networkd permissions
|
||||||
if err := os.WriteFile(networkdConfFile, []byte(networkdConfContent), 0644); err != nil {
|
if err := os.WriteFile(networkdConfFile, []byte(networkdConfContent), 0644); err != nil {
|
||||||
return fmt.Errorf("write networkd configuration: %w", err)
|
return fmt.Errorf("write networkd configuration: %w", err)
|
||||||
|
|||||||
224
client/cmd/service_params.go
Normal file
224
client/cmd/service_params.go
Normal file
@@ -0,0 +1,224 @@
|
|||||||
|
//go:build !ios && !android
|
||||||
|
|
||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"maps"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
|
"github.com/netbirdio/netbird/client/configs"
|
||||||
|
"github.com/netbirdio/netbird/util"
|
||||||
|
)
|
||||||
|
|
||||||
|
const serviceParamsFile = "service.json"
|
||||||
|
|
||||||
|
// serviceParams holds install-time service parameters that persist across
|
||||||
|
// uninstall/reinstall cycles. Saved to <stateDir>/service.json.
|
||||||
|
type serviceParams struct {
|
||||||
|
LogLevel string `json:"log_level"`
|
||||||
|
DaemonAddr string `json:"daemon_addr"`
|
||||||
|
ManagementURL string `json:"management_url,omitempty"`
|
||||||
|
ConfigPath string `json:"config_path,omitempty"`
|
||||||
|
LogFiles []string `json:"log_files,omitempty"`
|
||||||
|
DisableProfiles bool `json:"disable_profiles,omitempty"`
|
||||||
|
DisableUpdateSettings bool `json:"disable_update_settings,omitempty"`
|
||||||
|
EnableCapture bool `json:"enable_capture,omitempty"`
|
||||||
|
DisableNetworks bool `json:"disable_networks,omitempty"`
|
||||||
|
ServiceEnvVars map[string]string `json:"service_env_vars,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// serviceParamsPath returns the path to the service params file.
|
||||||
|
func serviceParamsPath() string {
|
||||||
|
return filepath.Join(configs.StateDir, serviceParamsFile)
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadServiceParams reads saved service parameters from disk.
|
||||||
|
// Returns nil with no error if the file does not exist.
|
||||||
|
func loadServiceParams() (*serviceParams, error) {
|
||||||
|
path := serviceParamsPath()
|
||||||
|
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return nil, nil //nolint:nilnil
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("read service params %s: %w", path, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var params serviceParams
|
||||||
|
if err := json.Unmarshal(data, ¶ms); err != nil {
|
||||||
|
return nil, fmt.Errorf("parse service params %s: %w", path, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return ¶ms, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// saveServiceParams writes current service parameters to disk atomically
|
||||||
|
// with restricted permissions.
|
||||||
|
func saveServiceParams(params *serviceParams) error {
|
||||||
|
path := serviceParamsPath()
|
||||||
|
if err := util.WriteJsonWithRestrictedPermission(context.Background(), path, params); err != nil {
|
||||||
|
return fmt.Errorf("save service params: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// currentServiceParams captures the current state of all package-level
|
||||||
|
// variables into a serviceParams struct.
|
||||||
|
func currentServiceParams() *serviceParams {
|
||||||
|
params := &serviceParams{
|
||||||
|
LogLevel: logLevel,
|
||||||
|
DaemonAddr: daemonAddr,
|
||||||
|
ManagementURL: managementURL,
|
||||||
|
ConfigPath: configPath,
|
||||||
|
LogFiles: logFiles,
|
||||||
|
DisableProfiles: profilesDisabled,
|
||||||
|
DisableUpdateSettings: updateSettingsDisabled,
|
||||||
|
EnableCapture: captureEnabled,
|
||||||
|
DisableNetworks: networksDisabled,
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(serviceEnvVars) > 0 {
|
||||||
|
parsed, err := parseServiceEnvVars(serviceEnvVars)
|
||||||
|
if err == nil {
|
||||||
|
params.ServiceEnvVars = parsed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return params
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadAndApplyServiceParams loads saved params from disk and applies them
|
||||||
|
// to any flags that were not explicitly set.
|
||||||
|
func loadAndApplyServiceParams(cmd *cobra.Command) error {
|
||||||
|
params, err := loadServiceParams()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
applyServiceParams(cmd, params)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyServiceParams merges saved parameters into package-level variables
|
||||||
|
// for any flag that was not explicitly set by the user (via CLI or env var).
|
||||||
|
// Flags that were Changed() are left untouched.
|
||||||
|
func applyServiceParams(cmd *cobra.Command, params *serviceParams) {
|
||||||
|
if params == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// For fields with non-empty defaults (log-level, daemon-addr), keep the
|
||||||
|
// != "" guard so that an older service.json missing the field doesn't
|
||||||
|
// clobber the default with an empty string.
|
||||||
|
if !rootCmd.PersistentFlags().Changed("log-level") && params.LogLevel != "" {
|
||||||
|
logLevel = params.LogLevel
|
||||||
|
}
|
||||||
|
|
||||||
|
if !rootCmd.PersistentFlags().Changed("daemon-addr") && params.DaemonAddr != "" {
|
||||||
|
daemonAddr = params.DaemonAddr
|
||||||
|
}
|
||||||
|
|
||||||
|
// For optional fields where empty means "use default", always apply so
|
||||||
|
// that an explicit clear (--management-url "") persists across reinstalls.
|
||||||
|
if !rootCmd.PersistentFlags().Changed("management-url") {
|
||||||
|
managementURL = params.ManagementURL
|
||||||
|
}
|
||||||
|
|
||||||
|
if !rootCmd.PersistentFlags().Changed("config") {
|
||||||
|
configPath = params.ConfigPath
|
||||||
|
}
|
||||||
|
|
||||||
|
if !rootCmd.PersistentFlags().Changed("log-file") {
|
||||||
|
logFiles = params.LogFiles
|
||||||
|
}
|
||||||
|
|
||||||
|
if !serviceCmd.PersistentFlags().Changed("disable-profiles") {
|
||||||
|
profilesDisabled = params.DisableProfiles
|
||||||
|
}
|
||||||
|
|
||||||
|
if !serviceCmd.PersistentFlags().Changed("disable-update-settings") {
|
||||||
|
updateSettingsDisabled = params.DisableUpdateSettings
|
||||||
|
}
|
||||||
|
|
||||||
|
if !serviceCmd.PersistentFlags().Changed("enable-capture") {
|
||||||
|
captureEnabled = params.EnableCapture
|
||||||
|
}
|
||||||
|
|
||||||
|
if !serviceCmd.PersistentFlags().Changed("disable-networks") {
|
||||||
|
networksDisabled = params.DisableNetworks
|
||||||
|
}
|
||||||
|
|
||||||
|
applyServiceEnvParams(cmd, params)
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyServiceEnvParams merges saved service environment variables.
|
||||||
|
// If --service-env was explicitly set with values, explicit values win on key
|
||||||
|
// conflict but saved keys not in the explicit set are carried over.
|
||||||
|
// If --service-env was explicitly set to empty, all saved env vars are cleared.
|
||||||
|
// If --service-env was not set, saved env vars are used entirely.
|
||||||
|
func applyServiceEnvParams(cmd *cobra.Command, params *serviceParams) {
|
||||||
|
if !cmd.Flags().Changed("service-env") {
|
||||||
|
if len(params.ServiceEnvVars) > 0 {
|
||||||
|
// No explicit env vars: rebuild serviceEnvVars from saved params.
|
||||||
|
serviceEnvVars = envMapToSlice(params.ServiceEnvVars)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flag was explicitly set: parse what the user provided.
|
||||||
|
explicit, err := parseServiceEnvVars(serviceEnvVars)
|
||||||
|
if err != nil {
|
||||||
|
cmd.PrintErrf("Warning: parse explicit service env vars for merge: %v\n", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the user passed an empty value (e.g. --service-env ""), clear all
|
||||||
|
// saved env vars rather than merging.
|
||||||
|
if len(explicit) == 0 {
|
||||||
|
serviceEnvVars = nil
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(params.ServiceEnvVars) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge saved values underneath explicit ones.
|
||||||
|
merged := make(map[string]string, len(params.ServiceEnvVars)+len(explicit))
|
||||||
|
maps.Copy(merged, params.ServiceEnvVars)
|
||||||
|
maps.Copy(merged, explicit) // explicit wins on conflict
|
||||||
|
serviceEnvVars = envMapToSlice(merged)
|
||||||
|
}
|
||||||
|
|
||||||
|
var resetParamsCmd = &cobra.Command{
|
||||||
|
Use: "reset-params",
|
||||||
|
Short: "Remove saved service install parameters",
|
||||||
|
Long: "Removes the saved service.json file so the next install uses default parameters.",
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
path := serviceParamsPath()
|
||||||
|
if err := os.Remove(path); err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
cmd.Println("No saved service parameters found")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return fmt.Errorf("remove service params: %w", err)
|
||||||
|
}
|
||||||
|
cmd.Printf("Removed saved service parameters (%s)\n", path)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// envMapToSlice converts a map of env vars to a KEY=VALUE slice.
|
||||||
|
func envMapToSlice(m map[string]string) []string {
|
||||||
|
s := make([]string, 0, len(m))
|
||||||
|
for k, v := range m {
|
||||||
|
s = append(s, k+"="+v)
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
560
client/cmd/service_params_test.go
Normal file
560
client/cmd/service_params_test.go
Normal file
@@ -0,0 +1,560 @@
|
|||||||
|
//go:build !ios && !android
|
||||||
|
|
||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"go/ast"
|
||||||
|
"go/parser"
|
||||||
|
"go/token"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
"github.com/spf13/pflag"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"github.com/netbirdio/netbird/client/configs"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestServiceParamsPath(t *testing.T) {
|
||||||
|
original := configs.StateDir
|
||||||
|
t.Cleanup(func() { configs.StateDir = original })
|
||||||
|
|
||||||
|
configs.StateDir = "/var/lib/netbird"
|
||||||
|
assert.Equal(t, filepath.Join("/var/lib/netbird", "service.json"), serviceParamsPath())
|
||||||
|
|
||||||
|
configs.StateDir = "/custom/state"
|
||||||
|
assert.Equal(t, filepath.Join("/custom/state", "service.json"), serviceParamsPath())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSaveAndLoadServiceParams(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
|
||||||
|
original := configs.StateDir
|
||||||
|
t.Cleanup(func() { configs.StateDir = original })
|
||||||
|
configs.StateDir = tmpDir
|
||||||
|
|
||||||
|
params := &serviceParams{
|
||||||
|
LogLevel: "debug",
|
||||||
|
DaemonAddr: "unix:///var/run/netbird.sock",
|
||||||
|
ManagementURL: "https://my.server.com",
|
||||||
|
ConfigPath: "/etc/netbird/config.json",
|
||||||
|
LogFiles: []string{"/var/log/netbird/client.log", "console"},
|
||||||
|
DisableProfiles: true,
|
||||||
|
DisableUpdateSettings: false,
|
||||||
|
ServiceEnvVars: map[string]string{"NB_LOG_FORMAT": "json", "CUSTOM": "val"},
|
||||||
|
}
|
||||||
|
|
||||||
|
err := saveServiceParams(params)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Verify the file exists and is valid JSON.
|
||||||
|
data, err := os.ReadFile(filepath.Join(tmpDir, "service.json"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, json.Valid(data))
|
||||||
|
|
||||||
|
loaded, err := loadServiceParams()
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, loaded)
|
||||||
|
|
||||||
|
assert.Equal(t, params.LogLevel, loaded.LogLevel)
|
||||||
|
assert.Equal(t, params.DaemonAddr, loaded.DaemonAddr)
|
||||||
|
assert.Equal(t, params.ManagementURL, loaded.ManagementURL)
|
||||||
|
assert.Equal(t, params.ConfigPath, loaded.ConfigPath)
|
||||||
|
assert.Equal(t, params.LogFiles, loaded.LogFiles)
|
||||||
|
assert.Equal(t, params.DisableProfiles, loaded.DisableProfiles)
|
||||||
|
assert.Equal(t, params.DisableUpdateSettings, loaded.DisableUpdateSettings)
|
||||||
|
assert.Equal(t, params.ServiceEnvVars, loaded.ServiceEnvVars)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadServiceParams_FileNotExists(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
|
||||||
|
original := configs.StateDir
|
||||||
|
t.Cleanup(func() { configs.StateDir = original })
|
||||||
|
configs.StateDir = tmpDir
|
||||||
|
|
||||||
|
params, err := loadServiceParams()
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Nil(t, params)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadServiceParams_InvalidJSON(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
|
||||||
|
original := configs.StateDir
|
||||||
|
t.Cleanup(func() { configs.StateDir = original })
|
||||||
|
configs.StateDir = tmpDir
|
||||||
|
|
||||||
|
err := os.WriteFile(filepath.Join(tmpDir, "service.json"), []byte("not json"), 0600)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
params, err := loadServiceParams()
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Nil(t, params)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCurrentServiceParams(t *testing.T) {
|
||||||
|
origLogLevel := logLevel
|
||||||
|
origDaemonAddr := daemonAddr
|
||||||
|
origManagementURL := managementURL
|
||||||
|
origConfigPath := configPath
|
||||||
|
origLogFiles := logFiles
|
||||||
|
origProfilesDisabled := profilesDisabled
|
||||||
|
origUpdateSettingsDisabled := updateSettingsDisabled
|
||||||
|
origServiceEnvVars := serviceEnvVars
|
||||||
|
t.Cleanup(func() {
|
||||||
|
logLevel = origLogLevel
|
||||||
|
daemonAddr = origDaemonAddr
|
||||||
|
managementURL = origManagementURL
|
||||||
|
configPath = origConfigPath
|
||||||
|
logFiles = origLogFiles
|
||||||
|
profilesDisabled = origProfilesDisabled
|
||||||
|
updateSettingsDisabled = origUpdateSettingsDisabled
|
||||||
|
serviceEnvVars = origServiceEnvVars
|
||||||
|
})
|
||||||
|
|
||||||
|
logLevel = "trace"
|
||||||
|
daemonAddr = "tcp://127.0.0.1:9999"
|
||||||
|
managementURL = "https://mgmt.example.com"
|
||||||
|
configPath = "/tmp/test-config.json"
|
||||||
|
logFiles = []string{"/tmp/test.log"}
|
||||||
|
profilesDisabled = true
|
||||||
|
updateSettingsDisabled = true
|
||||||
|
serviceEnvVars = []string{"FOO=bar", "BAZ=qux"}
|
||||||
|
|
||||||
|
params := currentServiceParams()
|
||||||
|
|
||||||
|
assert.Equal(t, "trace", params.LogLevel)
|
||||||
|
assert.Equal(t, "tcp://127.0.0.1:9999", params.DaemonAddr)
|
||||||
|
assert.Equal(t, "https://mgmt.example.com", params.ManagementURL)
|
||||||
|
assert.Equal(t, "/tmp/test-config.json", params.ConfigPath)
|
||||||
|
assert.Equal(t, []string{"/tmp/test.log"}, params.LogFiles)
|
||||||
|
assert.True(t, params.DisableProfiles)
|
||||||
|
assert.True(t, params.DisableUpdateSettings)
|
||||||
|
assert.Equal(t, map[string]string{"FOO": "bar", "BAZ": "qux"}, params.ServiceEnvVars)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyServiceParams_OnlyUnchangedFlags(t *testing.T) {
|
||||||
|
origLogLevel := logLevel
|
||||||
|
origDaemonAddr := daemonAddr
|
||||||
|
origManagementURL := managementURL
|
||||||
|
origConfigPath := configPath
|
||||||
|
origLogFiles := logFiles
|
||||||
|
origProfilesDisabled := profilesDisabled
|
||||||
|
origUpdateSettingsDisabled := updateSettingsDisabled
|
||||||
|
origServiceEnvVars := serviceEnvVars
|
||||||
|
t.Cleanup(func() {
|
||||||
|
logLevel = origLogLevel
|
||||||
|
daemonAddr = origDaemonAddr
|
||||||
|
managementURL = origManagementURL
|
||||||
|
configPath = origConfigPath
|
||||||
|
logFiles = origLogFiles
|
||||||
|
profilesDisabled = origProfilesDisabled
|
||||||
|
updateSettingsDisabled = origUpdateSettingsDisabled
|
||||||
|
serviceEnvVars = origServiceEnvVars
|
||||||
|
})
|
||||||
|
|
||||||
|
// Reset all flags to defaults.
|
||||||
|
logLevel = "info"
|
||||||
|
daemonAddr = "unix:///var/run/netbird.sock"
|
||||||
|
managementURL = ""
|
||||||
|
configPath = "/etc/netbird/config.json"
|
||||||
|
logFiles = []string{"/var/log/netbird/client.log"}
|
||||||
|
profilesDisabled = false
|
||||||
|
updateSettingsDisabled = false
|
||||||
|
serviceEnvVars = nil
|
||||||
|
|
||||||
|
// Reset Changed state on all relevant flags.
|
||||||
|
rootCmd.PersistentFlags().VisitAll(func(f *pflag.Flag) {
|
||||||
|
f.Changed = false
|
||||||
|
})
|
||||||
|
serviceCmd.PersistentFlags().VisitAll(func(f *pflag.Flag) {
|
||||||
|
f.Changed = false
|
||||||
|
})
|
||||||
|
|
||||||
|
// Simulate user explicitly setting --log-level via CLI.
|
||||||
|
logLevel = "warn"
|
||||||
|
require.NoError(t, rootCmd.PersistentFlags().Set("log-level", "warn"))
|
||||||
|
|
||||||
|
saved := &serviceParams{
|
||||||
|
LogLevel: "debug",
|
||||||
|
DaemonAddr: "tcp://127.0.0.1:5555",
|
||||||
|
ManagementURL: "https://saved.example.com",
|
||||||
|
ConfigPath: "/saved/config.json",
|
||||||
|
LogFiles: []string{"/saved/client.log"},
|
||||||
|
DisableProfiles: true,
|
||||||
|
DisableUpdateSettings: true,
|
||||||
|
ServiceEnvVars: map[string]string{"SAVED_KEY": "saved_val"},
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := &cobra.Command{}
|
||||||
|
cmd.Flags().StringSlice("service-env", nil, "")
|
||||||
|
applyServiceParams(cmd, saved)
|
||||||
|
|
||||||
|
// log-level was Changed, so it should keep "warn", not use saved "debug".
|
||||||
|
assert.Equal(t, "warn", logLevel)
|
||||||
|
|
||||||
|
// All other fields were not Changed, so they should use saved values.
|
||||||
|
assert.Equal(t, "tcp://127.0.0.1:5555", daemonAddr)
|
||||||
|
assert.Equal(t, "https://saved.example.com", managementURL)
|
||||||
|
assert.Equal(t, "/saved/config.json", configPath)
|
||||||
|
assert.Equal(t, []string{"/saved/client.log"}, logFiles)
|
||||||
|
assert.True(t, profilesDisabled)
|
||||||
|
assert.True(t, updateSettingsDisabled)
|
||||||
|
assert.Equal(t, []string{"SAVED_KEY=saved_val"}, serviceEnvVars)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyServiceParams_BooleanRevertToFalse(t *testing.T) {
|
||||||
|
origProfilesDisabled := profilesDisabled
|
||||||
|
origUpdateSettingsDisabled := updateSettingsDisabled
|
||||||
|
t.Cleanup(func() {
|
||||||
|
profilesDisabled = origProfilesDisabled
|
||||||
|
updateSettingsDisabled = origUpdateSettingsDisabled
|
||||||
|
})
|
||||||
|
|
||||||
|
// Simulate current state where booleans are true (e.g. set by previous install).
|
||||||
|
profilesDisabled = true
|
||||||
|
updateSettingsDisabled = true
|
||||||
|
|
||||||
|
// Reset Changed state so flags appear unset.
|
||||||
|
serviceCmd.PersistentFlags().VisitAll(func(f *pflag.Flag) {
|
||||||
|
f.Changed = false
|
||||||
|
})
|
||||||
|
|
||||||
|
// Saved params have both as false.
|
||||||
|
saved := &serviceParams{
|
||||||
|
DisableProfiles: false,
|
||||||
|
DisableUpdateSettings: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := &cobra.Command{}
|
||||||
|
cmd.Flags().StringSlice("service-env", nil, "")
|
||||||
|
applyServiceParams(cmd, saved)
|
||||||
|
|
||||||
|
assert.False(t, profilesDisabled, "saved false should override current true")
|
||||||
|
assert.False(t, updateSettingsDisabled, "saved false should override current true")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyServiceParams_ClearManagementURL(t *testing.T) {
|
||||||
|
origManagementURL := managementURL
|
||||||
|
t.Cleanup(func() { managementURL = origManagementURL })
|
||||||
|
|
||||||
|
managementURL = "https://leftover.example.com"
|
||||||
|
|
||||||
|
// Simulate saved params where management URL was explicitly cleared.
|
||||||
|
saved := &serviceParams{
|
||||||
|
LogLevel: "info",
|
||||||
|
DaemonAddr: "unix:///var/run/netbird.sock",
|
||||||
|
// ManagementURL intentionally empty: was cleared with --management-url "".
|
||||||
|
}
|
||||||
|
|
||||||
|
rootCmd.PersistentFlags().VisitAll(func(f *pflag.Flag) {
|
||||||
|
f.Changed = false
|
||||||
|
})
|
||||||
|
|
||||||
|
cmd := &cobra.Command{}
|
||||||
|
cmd.Flags().StringSlice("service-env", nil, "")
|
||||||
|
applyServiceParams(cmd, saved)
|
||||||
|
|
||||||
|
assert.Equal(t, "", managementURL, "saved empty management URL should clear the current value")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyServiceParams_NilParams(t *testing.T) {
|
||||||
|
origLogLevel := logLevel
|
||||||
|
t.Cleanup(func() { logLevel = origLogLevel })
|
||||||
|
|
||||||
|
logLevel = "info"
|
||||||
|
cmd := &cobra.Command{}
|
||||||
|
cmd.Flags().StringSlice("service-env", nil, "")
|
||||||
|
|
||||||
|
// Should be a no-op.
|
||||||
|
applyServiceParams(cmd, nil)
|
||||||
|
assert.Equal(t, "info", logLevel)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyServiceEnvParams_MergeExplicitAndSaved(t *testing.T) {
|
||||||
|
origServiceEnvVars := serviceEnvVars
|
||||||
|
t.Cleanup(func() { serviceEnvVars = origServiceEnvVars })
|
||||||
|
|
||||||
|
// Set up a command with --service-env marked as Changed.
|
||||||
|
cmd := &cobra.Command{}
|
||||||
|
cmd.Flags().StringSlice("service-env", nil, "")
|
||||||
|
require.NoError(t, cmd.Flags().Set("service-env", "EXPLICIT=yes,OVERLAP=explicit"))
|
||||||
|
|
||||||
|
serviceEnvVars = []string{"EXPLICIT=yes", "OVERLAP=explicit"}
|
||||||
|
|
||||||
|
saved := &serviceParams{
|
||||||
|
ServiceEnvVars: map[string]string{
|
||||||
|
"SAVED": "val",
|
||||||
|
"OVERLAP": "saved",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
applyServiceEnvParams(cmd, saved)
|
||||||
|
|
||||||
|
// Parse result for easier assertion.
|
||||||
|
result, err := parseServiceEnvVars(serviceEnvVars)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Equal(t, "yes", result["EXPLICIT"])
|
||||||
|
assert.Equal(t, "val", result["SAVED"])
|
||||||
|
// Explicit wins on conflict.
|
||||||
|
assert.Equal(t, "explicit", result["OVERLAP"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyServiceEnvParams_NotChanged(t *testing.T) {
|
||||||
|
origServiceEnvVars := serviceEnvVars
|
||||||
|
t.Cleanup(func() { serviceEnvVars = origServiceEnvVars })
|
||||||
|
|
||||||
|
serviceEnvVars = nil
|
||||||
|
|
||||||
|
cmd := &cobra.Command{}
|
||||||
|
cmd.Flags().StringSlice("service-env", nil, "")
|
||||||
|
|
||||||
|
saved := &serviceParams{
|
||||||
|
ServiceEnvVars: map[string]string{"FROM_SAVED": "val"},
|
||||||
|
}
|
||||||
|
|
||||||
|
applyServiceEnvParams(cmd, saved)
|
||||||
|
|
||||||
|
result, err := parseServiceEnvVars(serviceEnvVars)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, map[string]string{"FROM_SAVED": "val"}, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyServiceEnvParams_ExplicitEmptyClears(t *testing.T) {
|
||||||
|
origServiceEnvVars := serviceEnvVars
|
||||||
|
t.Cleanup(func() { serviceEnvVars = origServiceEnvVars })
|
||||||
|
|
||||||
|
// Simulate --service-env "" which produces [""] in the slice.
|
||||||
|
serviceEnvVars = []string{""}
|
||||||
|
|
||||||
|
cmd := &cobra.Command{}
|
||||||
|
cmd.Flags().StringSlice("service-env", nil, "")
|
||||||
|
require.NoError(t, cmd.Flags().Set("service-env", ""))
|
||||||
|
|
||||||
|
saved := &serviceParams{
|
||||||
|
ServiceEnvVars: map[string]string{"OLD_VAR": "should_be_cleared"},
|
||||||
|
}
|
||||||
|
|
||||||
|
applyServiceEnvParams(cmd, saved)
|
||||||
|
|
||||||
|
assert.Nil(t, serviceEnvVars, "explicit empty --service-env should clear all saved env vars")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCurrentServiceParams_EmptyEnvVarsAfterParse(t *testing.T) {
|
||||||
|
origServiceEnvVars := serviceEnvVars
|
||||||
|
t.Cleanup(func() { serviceEnvVars = origServiceEnvVars })
|
||||||
|
|
||||||
|
// Simulate --service-env "" which produces [""] in the slice.
|
||||||
|
serviceEnvVars = []string{""}
|
||||||
|
|
||||||
|
params := currentServiceParams()
|
||||||
|
|
||||||
|
// After parsing, the empty string is skipped, resulting in an empty map.
|
||||||
|
// The map should still be set (not nil) so it overwrites saved values.
|
||||||
|
assert.NotNil(t, params.ServiceEnvVars, "empty env vars should produce empty map, not nil")
|
||||||
|
assert.Empty(t, params.ServiceEnvVars, "no valid env vars should be parsed from empty string")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestServiceParams_FieldsCoveredInFunctions ensures that all serviceParams fields are
|
||||||
|
// referenced in both currentServiceParams() and applyServiceParams(). If a new field is
|
||||||
|
// added to serviceParams but not wired into these functions, this test fails.
|
||||||
|
func TestServiceParams_FieldsCoveredInFunctions(t *testing.T) {
|
||||||
|
fset := token.NewFileSet()
|
||||||
|
file, err := parser.ParseFile(fset, "service_params.go", nil, 0)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Collect all JSON field names from the serviceParams struct.
|
||||||
|
structFields := extractStructJSONFields(t, file, "serviceParams")
|
||||||
|
require.NotEmpty(t, structFields, "failed to find serviceParams struct fields")
|
||||||
|
|
||||||
|
// Collect field names referenced in currentServiceParams and applyServiceParams.
|
||||||
|
currentFields := extractFuncFieldRefs(t, file, "currentServiceParams", structFields)
|
||||||
|
applyFields := extractFuncFieldRefs(t, file, "applyServiceParams", structFields)
|
||||||
|
// applyServiceEnvParams handles ServiceEnvVars indirectly.
|
||||||
|
applyEnvFields := extractFuncFieldRefs(t, file, "applyServiceEnvParams", structFields)
|
||||||
|
for k, v := range applyEnvFields {
|
||||||
|
applyFields[k] = v
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, field := range structFields {
|
||||||
|
assert.Contains(t, currentFields, field,
|
||||||
|
"serviceParams field %q is not captured in currentServiceParams()", field)
|
||||||
|
assert.Contains(t, applyFields, field,
|
||||||
|
"serviceParams field %q is not restored in applyServiceParams()/applyServiceEnvParams()", field)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestServiceParams_BuildArgsCoversAllFlags ensures that buildServiceArguments references
|
||||||
|
// all serviceParams fields that should become CLI args. ServiceEnvVars is excluded because
|
||||||
|
// it flows through newSVCConfig() EnvVars, not CLI args.
|
||||||
|
func TestServiceParams_BuildArgsCoversAllFlags(t *testing.T) {
|
||||||
|
fset := token.NewFileSet()
|
||||||
|
file, err := parser.ParseFile(fset, "service_params.go", nil, 0)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
structFields := extractStructJSONFields(t, file, "serviceParams")
|
||||||
|
require.NotEmpty(t, structFields)
|
||||||
|
|
||||||
|
installerFile, err := parser.ParseFile(fset, "service_installer.go", nil, 0)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Fields that are handled outside of buildServiceArguments (env vars go through newSVCConfig).
|
||||||
|
fieldsNotInArgs := map[string]bool{
|
||||||
|
"ServiceEnvVars": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
buildFields := extractFuncGlobalRefs(t, installerFile, "buildServiceArguments")
|
||||||
|
|
||||||
|
// Forward: every struct field must appear in buildServiceArguments.
|
||||||
|
for _, field := range structFields {
|
||||||
|
if fieldsNotInArgs[field] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
globalVar := fieldToGlobalVar(field)
|
||||||
|
assert.Contains(t, buildFields, globalVar,
|
||||||
|
"serviceParams field %q (global %q) is not referenced in buildServiceArguments()", field, globalVar)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reverse: every service-related global used in buildServiceArguments must
|
||||||
|
// have a corresponding serviceParams field. This catches a developer adding
|
||||||
|
// a new flag to buildServiceArguments without adding it to the struct.
|
||||||
|
globalToField := make(map[string]string, len(structFields))
|
||||||
|
for _, field := range structFields {
|
||||||
|
globalToField[fieldToGlobalVar(field)] = field
|
||||||
|
}
|
||||||
|
// Identifiers in buildServiceArguments that are not service params
|
||||||
|
// (builtins, boilerplate, loop variables).
|
||||||
|
nonParamGlobals := map[string]bool{
|
||||||
|
"args": true, "append": true, "string": true, "_": true,
|
||||||
|
"logFile": true, // range variable over logFiles
|
||||||
|
}
|
||||||
|
for ref := range buildFields {
|
||||||
|
if nonParamGlobals[ref] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
_, inStruct := globalToField[ref]
|
||||||
|
assert.True(t, inStruct,
|
||||||
|
"buildServiceArguments() references global %q which has no corresponding serviceParams field", ref)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractStructJSONFields returns field names from a named struct type.
|
||||||
|
func extractStructJSONFields(t *testing.T, file *ast.File, structName string) []string {
|
||||||
|
t.Helper()
|
||||||
|
var fields []string
|
||||||
|
ast.Inspect(file, func(n ast.Node) bool {
|
||||||
|
ts, ok := n.(*ast.TypeSpec)
|
||||||
|
if !ok || ts.Name.Name != structName {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
st, ok := ts.Type.(*ast.StructType)
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, f := range st.Fields.List {
|
||||||
|
if len(f.Names) > 0 {
|
||||||
|
fields = append(fields, f.Names[0].Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
return fields
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractFuncFieldRefs returns which of the given field names appear inside the
|
||||||
|
// named function, either as selector expressions (params.FieldName) or as
|
||||||
|
// composite literal keys (&serviceParams{FieldName: ...}).
|
||||||
|
func extractFuncFieldRefs(t *testing.T, file *ast.File, funcName string, fields []string) map[string]bool {
|
||||||
|
t.Helper()
|
||||||
|
fieldSet := make(map[string]bool, len(fields))
|
||||||
|
for _, f := range fields {
|
||||||
|
fieldSet[f] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
found := make(map[string]bool)
|
||||||
|
fn := findFuncDecl(file, funcName)
|
||||||
|
require.NotNil(t, fn, "function %s not found", funcName)
|
||||||
|
|
||||||
|
ast.Inspect(fn.Body, func(n ast.Node) bool {
|
||||||
|
switch v := n.(type) {
|
||||||
|
case *ast.SelectorExpr:
|
||||||
|
if fieldSet[v.Sel.Name] {
|
||||||
|
found[v.Sel.Name] = true
|
||||||
|
}
|
||||||
|
case *ast.KeyValueExpr:
|
||||||
|
if ident, ok := v.Key.(*ast.Ident); ok && fieldSet[ident.Name] {
|
||||||
|
found[ident.Name] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
return found
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractFuncGlobalRefs returns all identifier names referenced in the named function body.
|
||||||
|
func extractFuncGlobalRefs(t *testing.T, file *ast.File, funcName string) map[string]bool {
|
||||||
|
t.Helper()
|
||||||
|
fn := findFuncDecl(file, funcName)
|
||||||
|
require.NotNil(t, fn, "function %s not found", funcName)
|
||||||
|
|
||||||
|
refs := make(map[string]bool)
|
||||||
|
ast.Inspect(fn.Body, func(n ast.Node) bool {
|
||||||
|
if ident, ok := n.(*ast.Ident); ok {
|
||||||
|
refs[ident.Name] = true
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
return refs
|
||||||
|
}
|
||||||
|
|
||||||
|
func findFuncDecl(file *ast.File, name string) *ast.FuncDecl {
|
||||||
|
for _, decl := range file.Decls {
|
||||||
|
fn, ok := decl.(*ast.FuncDecl)
|
||||||
|
if ok && fn.Name.Name == name {
|
||||||
|
return fn
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// fieldToGlobalVar maps serviceParams field names to the package-level variable
|
||||||
|
// names used in buildServiceArguments and applyServiceParams.
|
||||||
|
func fieldToGlobalVar(field string) string {
|
||||||
|
m := map[string]string{
|
||||||
|
"LogLevel": "logLevel",
|
||||||
|
"DaemonAddr": "daemonAddr",
|
||||||
|
"ManagementURL": "managementURL",
|
||||||
|
"ConfigPath": "configPath",
|
||||||
|
"LogFiles": "logFiles",
|
||||||
|
"DisableProfiles": "profilesDisabled",
|
||||||
|
"DisableUpdateSettings": "updateSettingsDisabled",
|
||||||
|
"EnableCapture": "captureEnabled",
|
||||||
|
"DisableNetworks": "networksDisabled",
|
||||||
|
"ServiceEnvVars": "serviceEnvVars",
|
||||||
|
}
|
||||||
|
if v, ok := m[field]; ok {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
// Default: lowercase first letter.
|
||||||
|
return strings.ToLower(field[:1]) + field[1:]
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnvMapToSlice(t *testing.T) {
|
||||||
|
m := map[string]string{"A": "1", "B": "2"}
|
||||||
|
s := envMapToSlice(m)
|
||||||
|
assert.Len(t, s, 2)
|
||||||
|
assert.Contains(t, s, "A=1")
|
||||||
|
assert.Contains(t, s, "B=2")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnvMapToSlice_Empty(t *testing.T) {
|
||||||
|
s := envMapToSlice(map[string]string{})
|
||||||
|
assert.Empty(t, s)
|
||||||
|
}
|
||||||
@@ -4,7 +4,9 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
"os/signal"
|
||||||
"runtime"
|
"runtime"
|
||||||
|
"syscall"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -13,6 +15,22 @@ import (
|
|||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// TestMain intercepts when this test binary is run as a daemon subprocess.
|
||||||
|
// On FreeBSD, the rc.d service script runs the binary via daemon(8) -r with
|
||||||
|
// "service run ..." arguments. Since the test binary can't handle cobra CLI
|
||||||
|
// args, it exits immediately, causing daemon -r to respawn rapidly until
|
||||||
|
// hitting the rate limit and exiting. This makes service restart unreliable.
|
||||||
|
// Blocking here keeps the subprocess alive until the init system sends SIGTERM.
|
||||||
|
func TestMain(m *testing.M) {
|
||||||
|
if len(os.Args) > 2 && os.Args[1] == "service" && os.Args[2] == "run" {
|
||||||
|
sig := make(chan os.Signal, 1)
|
||||||
|
signal.Notify(sig, syscall.SIGTERM, os.Interrupt)
|
||||||
|
<-sig
|
||||||
|
return
|
||||||
|
}
|
||||||
|
os.Exit(m.Run())
|
||||||
|
}
|
||||||
|
|
||||||
const (
|
const (
|
||||||
serviceStartTimeout = 10 * time.Second
|
serviceStartTimeout = 10 * time.Second
|
||||||
serviceStopTimeout = 5 * time.Second
|
serviceStopTimeout = 5 * time.Second
|
||||||
@@ -79,6 +97,34 @@ func TestServiceLifecycle(t *testing.T) {
|
|||||||
logLevel = "info"
|
logLevel = "info"
|
||||||
daemonAddr = fmt.Sprintf("unix://%s/netbird-test.sock", tempDir)
|
daemonAddr = fmt.Sprintf("unix://%s/netbird-test.sock", tempDir)
|
||||||
|
|
||||||
|
// Ensure cleanup even if a subtest fails and Stop/Uninstall subtests don't run.
|
||||||
|
t.Cleanup(func() {
|
||||||
|
cfg, err := newSVCConfig()
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("cleanup: create service config: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctxSvc, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
s, err := newSVC(newProgram(ctxSvc, cancel), cfg)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("cleanup: create service: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the subtests already cleaned up, there's nothing to do.
|
||||||
|
if _, err := s.Status(); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.Stop(); err != nil {
|
||||||
|
t.Errorf("cleanup: stop service: %v", err)
|
||||||
|
}
|
||||||
|
if err := s.Uninstall(); err != nil {
|
||||||
|
t.Errorf("cleanup: uninstall service: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
t.Run("Install", func(t *testing.T) {
|
t.Run("Install", func(t *testing.T) {
|
||||||
|
|||||||
176
client/cmd/signer/artifactkey.go
Normal file
176
client/cmd/signer/artifactkey.go
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
|
"github.com/netbirdio/netbird/client/internal/updater/reposign"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
bundlePubKeysRootPrivKeyFile string
|
||||||
|
bundlePubKeysPubKeyFiles []string
|
||||||
|
bundlePubKeysFile string
|
||||||
|
|
||||||
|
createArtifactKeyRootPrivKeyFile string
|
||||||
|
createArtifactKeyPrivKeyFile string
|
||||||
|
createArtifactKeyPubKeyFile string
|
||||||
|
createArtifactKeyExpiration time.Duration
|
||||||
|
)
|
||||||
|
|
||||||
|
var createArtifactKeyCmd = &cobra.Command{
|
||||||
|
Use: "create-artifact-key",
|
||||||
|
Short: "Create a new artifact signing key",
|
||||||
|
Long: `Generate a new artifact signing key pair signed by the root private key.
|
||||||
|
The artifact key will be used to sign software artifacts/updates.`,
|
||||||
|
SilenceUsage: true,
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
if createArtifactKeyExpiration <= 0 {
|
||||||
|
return fmt.Errorf("--expiration must be a positive duration (e.g., 720h, 365d, 8760h)")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := handleCreateArtifactKey(cmd, createArtifactKeyRootPrivKeyFile, createArtifactKeyPrivKeyFile, createArtifactKeyPubKeyFile, createArtifactKeyExpiration); err != nil {
|
||||||
|
return fmt.Errorf("failed to create artifact key: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var bundlePubKeysCmd = &cobra.Command{
|
||||||
|
Use: "bundle-pub-keys",
|
||||||
|
Short: "Bundle multiple artifact public keys into a signed package",
|
||||||
|
Long: `Bundle one or more artifact public keys into a signed package using the root private key.
|
||||||
|
This command is typically used to distribute or authorize a set of valid artifact signing keys.`,
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
if len(bundlePubKeysPubKeyFiles) == 0 {
|
||||||
|
return fmt.Errorf("at least one --artifact-pub-key-file must be provided")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := handleBundlePubKeys(cmd, bundlePubKeysRootPrivKeyFile, bundlePubKeysPubKeyFiles, bundlePubKeysFile); err != nil {
|
||||||
|
return fmt.Errorf("failed to bundle public keys: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
rootCmd.AddCommand(createArtifactKeyCmd)
|
||||||
|
|
||||||
|
createArtifactKeyCmd.Flags().StringVar(&createArtifactKeyRootPrivKeyFile, "root-private-key-file", "", "Path to the root private key file used to sign the artifact key")
|
||||||
|
createArtifactKeyCmd.Flags().StringVar(&createArtifactKeyPrivKeyFile, "artifact-priv-key-file", "", "Path where the artifact private key will be saved")
|
||||||
|
createArtifactKeyCmd.Flags().StringVar(&createArtifactKeyPubKeyFile, "artifact-pub-key-file", "", "Path where the artifact public key will be saved")
|
||||||
|
createArtifactKeyCmd.Flags().DurationVar(&createArtifactKeyExpiration, "expiration", 0, "Expiration duration for the artifact key (e.g., 720h, 365d, 8760h)")
|
||||||
|
|
||||||
|
if err := createArtifactKeyCmd.MarkFlagRequired("root-private-key-file"); err != nil {
|
||||||
|
panic(fmt.Errorf("mark root-private-key-file as required: %w", err))
|
||||||
|
}
|
||||||
|
if err := createArtifactKeyCmd.MarkFlagRequired("artifact-priv-key-file"); err != nil {
|
||||||
|
panic(fmt.Errorf("mark artifact-priv-key-file as required: %w", err))
|
||||||
|
}
|
||||||
|
if err := createArtifactKeyCmd.MarkFlagRequired("artifact-pub-key-file"); err != nil {
|
||||||
|
panic(fmt.Errorf("mark artifact-pub-key-file as required: %w", err))
|
||||||
|
}
|
||||||
|
if err := createArtifactKeyCmd.MarkFlagRequired("expiration"); err != nil {
|
||||||
|
panic(fmt.Errorf("mark expiration as required: %w", err))
|
||||||
|
}
|
||||||
|
|
||||||
|
rootCmd.AddCommand(bundlePubKeysCmd)
|
||||||
|
|
||||||
|
bundlePubKeysCmd.Flags().StringVar(&bundlePubKeysRootPrivKeyFile, "root-private-key-file", "", "Path to the root private key file used to sign the bundle")
|
||||||
|
bundlePubKeysCmd.Flags().StringArrayVar(&bundlePubKeysPubKeyFiles, "artifact-pub-key-file", nil, "Path(s) to the artifact public key files to include in the bundle (can be repeated)")
|
||||||
|
bundlePubKeysCmd.Flags().StringVar(&bundlePubKeysFile, "bundle-pub-key-file", "", "Path where the public keys will be saved")
|
||||||
|
|
||||||
|
if err := bundlePubKeysCmd.MarkFlagRequired("root-private-key-file"); err != nil {
|
||||||
|
panic(fmt.Errorf("mark root-private-key-file as required: %w", err))
|
||||||
|
}
|
||||||
|
if err := bundlePubKeysCmd.MarkFlagRequired("artifact-pub-key-file"); err != nil {
|
||||||
|
panic(fmt.Errorf("mark artifact-pub-key-file as required: %w", err))
|
||||||
|
}
|
||||||
|
if err := bundlePubKeysCmd.MarkFlagRequired("bundle-pub-key-file"); err != nil {
|
||||||
|
panic(fmt.Errorf("mark bundle-pub-key-file as required: %w", err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleCreateArtifactKey(cmd *cobra.Command, rootPrivKeyFile, artifactPrivKeyFile, artifactPubKeyFile string, expiration time.Duration) error {
|
||||||
|
cmd.Println("Creating new artifact signing key...")
|
||||||
|
|
||||||
|
privKeyPEM, err := os.ReadFile(rootPrivKeyFile)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("read root private key file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
privateRootKey, err := reposign.ParseRootKey(privKeyPEM)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to parse private root key: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
artifactKey, privPEM, pubPEM, signature, err := reposign.GenerateArtifactKey(privateRootKey, expiration)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("generate artifact key: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.WriteFile(artifactPrivKeyFile, privPEM, 0o600); err != nil {
|
||||||
|
return fmt.Errorf("write private key file (%s): %w", artifactPrivKeyFile, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.WriteFile(artifactPubKeyFile, pubPEM, 0o600); err != nil {
|
||||||
|
return fmt.Errorf("write public key file (%s): %w", artifactPubKeyFile, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
signatureFile := artifactPubKeyFile + ".sig"
|
||||||
|
if err := os.WriteFile(signatureFile, signature, 0o600); err != nil {
|
||||||
|
return fmt.Errorf("write signature file (%s): %w", signatureFile, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.Printf("✅ Artifact key created successfully.\n")
|
||||||
|
cmd.Printf("%s\n", artifactKey.String())
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleBundlePubKeys(cmd *cobra.Command, rootPrivKeyFile string, artifactPubKeyFiles []string, bundlePubKeysFile string) error {
|
||||||
|
cmd.Println("📦 Bundling public keys into signed package...")
|
||||||
|
|
||||||
|
privKeyPEM, err := os.ReadFile(rootPrivKeyFile)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("read root private key file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
privateRootKey, err := reposign.ParseRootKey(privKeyPEM)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to parse private root key: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
publicKeys := make([]reposign.PublicKey, 0, len(artifactPubKeyFiles))
|
||||||
|
for _, pubFile := range artifactPubKeyFiles {
|
||||||
|
pubPem, err := os.ReadFile(pubFile)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("read public key file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
pk, err := reposign.ParseArtifactPubKey(pubPem)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to parse artifact key: %w", err)
|
||||||
|
}
|
||||||
|
publicKeys = append(publicKeys, pk)
|
||||||
|
}
|
||||||
|
|
||||||
|
parsedKeys, signature, err := reposign.BundleArtifactKeys(privateRootKey, publicKeys)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("bundle artifact keys: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.WriteFile(bundlePubKeysFile, parsedKeys, 0o600); err != nil {
|
||||||
|
return fmt.Errorf("write public keys file (%s): %w", bundlePubKeysFile, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
signatureFile := bundlePubKeysFile + ".sig"
|
||||||
|
if err := os.WriteFile(signatureFile, signature, 0o600); err != nil {
|
||||||
|
return fmt.Errorf("write signature file (%s): %w", signatureFile, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.Printf("✅ Bundle created with %d public keys.\n", len(artifactPubKeyFiles))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
276
client/cmd/signer/artifactsign.go
Normal file
276
client/cmd/signer/artifactsign.go
Normal file
@@ -0,0 +1,276 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
|
"github.com/netbirdio/netbird/client/internal/updater/reposign"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
envArtifactPrivateKey = "NB_ARTIFACT_PRIV_KEY"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
signArtifactPrivKeyFile string
|
||||||
|
signArtifactArtifactFile string
|
||||||
|
|
||||||
|
verifyArtifactPubKeyFile string
|
||||||
|
verifyArtifactFile string
|
||||||
|
verifyArtifactSignatureFile string
|
||||||
|
|
||||||
|
verifyArtifactKeyPubKeyFile string
|
||||||
|
verifyArtifactKeyRootPubKeyFile string
|
||||||
|
verifyArtifactKeySignatureFile string
|
||||||
|
verifyArtifactKeyRevocationFile string
|
||||||
|
)
|
||||||
|
|
||||||
|
var signArtifactCmd = &cobra.Command{
|
||||||
|
Use: "sign-artifact",
|
||||||
|
Short: "Sign an artifact using an artifact private key",
|
||||||
|
Long: `Sign a software artifact (e.g., update bundle or binary) using the artifact's private key.
|
||||||
|
This command produces a detached signature that can be verified using the corresponding artifact public key.`,
|
||||||
|
SilenceUsage: true,
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
if err := handleSignArtifact(cmd, signArtifactPrivKeyFile, signArtifactArtifactFile); err != nil {
|
||||||
|
return fmt.Errorf("failed to sign artifact: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var verifyArtifactCmd = &cobra.Command{
|
||||||
|
Use: "verify-artifact",
|
||||||
|
Short: "Verify an artifact signature using an artifact public key",
|
||||||
|
Long: `Verify a software artifact signature using the artifact's public key.`,
|
||||||
|
SilenceUsage: true,
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
if err := handleVerifyArtifact(cmd, verifyArtifactPubKeyFile, verifyArtifactFile, verifyArtifactSignatureFile); err != nil {
|
||||||
|
return fmt.Errorf("failed to verify artifact: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var verifyArtifactKeyCmd = &cobra.Command{
|
||||||
|
Use: "verify-artifact-key",
|
||||||
|
Short: "Verify an artifact public key was signed by a root key",
|
||||||
|
Long: `Verify that an artifact public key (or bundle) was properly signed by a root key.
|
||||||
|
This validates the chain of trust from the root key to the artifact key.`,
|
||||||
|
SilenceUsage: true,
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
if err := handleVerifyArtifactKey(cmd, verifyArtifactKeyPubKeyFile, verifyArtifactKeyRootPubKeyFile, verifyArtifactKeySignatureFile, verifyArtifactKeyRevocationFile); err != nil {
|
||||||
|
return fmt.Errorf("failed to verify artifact key: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
rootCmd.AddCommand(signArtifactCmd)
|
||||||
|
rootCmd.AddCommand(verifyArtifactCmd)
|
||||||
|
rootCmd.AddCommand(verifyArtifactKeyCmd)
|
||||||
|
|
||||||
|
signArtifactCmd.Flags().StringVar(&signArtifactPrivKeyFile, "artifact-key-file", "", fmt.Sprintf("Path to the artifact private key file used for signing (or set %s env var)", envArtifactPrivateKey))
|
||||||
|
signArtifactCmd.Flags().StringVar(&signArtifactArtifactFile, "artifact-file", "", "Path to the artifact to be signed")
|
||||||
|
|
||||||
|
// artifact-file is required, but artifact-key-file can come from env var
|
||||||
|
if err := signArtifactCmd.MarkFlagRequired("artifact-file"); err != nil {
|
||||||
|
panic(fmt.Errorf("mark artifact-file as required: %w", err))
|
||||||
|
}
|
||||||
|
|
||||||
|
verifyArtifactCmd.Flags().StringVar(&verifyArtifactPubKeyFile, "artifact-public-key-file", "", "Path to the artifact public key file")
|
||||||
|
verifyArtifactCmd.Flags().StringVar(&verifyArtifactFile, "artifact-file", "", "Path to the artifact to be verified")
|
||||||
|
verifyArtifactCmd.Flags().StringVar(&verifyArtifactSignatureFile, "signature-file", "", "Path to the signature file")
|
||||||
|
|
||||||
|
if err := verifyArtifactCmd.MarkFlagRequired("artifact-public-key-file"); err != nil {
|
||||||
|
panic(fmt.Errorf("mark artifact-public-key-file as required: %w", err))
|
||||||
|
}
|
||||||
|
if err := verifyArtifactCmd.MarkFlagRequired("artifact-file"); err != nil {
|
||||||
|
panic(fmt.Errorf("mark artifact-file as required: %w", err))
|
||||||
|
}
|
||||||
|
if err := verifyArtifactCmd.MarkFlagRequired("signature-file"); err != nil {
|
||||||
|
panic(fmt.Errorf("mark signature-file as required: %w", err))
|
||||||
|
}
|
||||||
|
|
||||||
|
verifyArtifactKeyCmd.Flags().StringVar(&verifyArtifactKeyPubKeyFile, "artifact-key-file", "", "Path to the artifact public key file or bundle")
|
||||||
|
verifyArtifactKeyCmd.Flags().StringVar(&verifyArtifactKeyRootPubKeyFile, "root-key-file", "", "Path to the root public key file or bundle")
|
||||||
|
verifyArtifactKeyCmd.Flags().StringVar(&verifyArtifactKeySignatureFile, "signature-file", "", "Path to the signature file")
|
||||||
|
verifyArtifactKeyCmd.Flags().StringVar(&verifyArtifactKeyRevocationFile, "revocation-file", "", "Path to the revocation list file (optional)")
|
||||||
|
|
||||||
|
if err := verifyArtifactKeyCmd.MarkFlagRequired("artifact-key-file"); err != nil {
|
||||||
|
panic(fmt.Errorf("mark artifact-key-file as required: %w", err))
|
||||||
|
}
|
||||||
|
if err := verifyArtifactKeyCmd.MarkFlagRequired("root-key-file"); err != nil {
|
||||||
|
panic(fmt.Errorf("mark root-key-file as required: %w", err))
|
||||||
|
}
|
||||||
|
if err := verifyArtifactKeyCmd.MarkFlagRequired("signature-file"); err != nil {
|
||||||
|
panic(fmt.Errorf("mark signature-file as required: %w", err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleSignArtifact(cmd *cobra.Command, privKeyFile, artifactFile string) error {
|
||||||
|
cmd.Println("🖋️ Signing artifact...")
|
||||||
|
|
||||||
|
// Load private key from env var or file
|
||||||
|
var privKeyPEM []byte
|
||||||
|
var err error
|
||||||
|
|
||||||
|
if envKey := os.Getenv(envArtifactPrivateKey); envKey != "" {
|
||||||
|
// Use key from environment variable
|
||||||
|
privKeyPEM = []byte(envKey)
|
||||||
|
} else if privKeyFile != "" {
|
||||||
|
// Fall back to file
|
||||||
|
privKeyPEM, err = os.ReadFile(privKeyFile)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("read private key file: %w", err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return fmt.Errorf("artifact private key must be provided via %s environment variable or --artifact-key-file flag", envArtifactPrivateKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
privateKey, err := reposign.ParseArtifactKey(privKeyPEM)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to parse artifact private key: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
artifactData, err := os.ReadFile(artifactFile)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("read artifact file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
signature, err := reposign.SignData(privateKey, artifactData)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("sign artifact: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
sigFile := artifactFile + ".sig"
|
||||||
|
if err := os.WriteFile(artifactFile+".sig", signature, 0o600); err != nil {
|
||||||
|
return fmt.Errorf("write signature file (%s): %w", sigFile, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.Printf("✅ Artifact signed successfully.\n")
|
||||||
|
cmd.Printf("Signature file: %s\n", sigFile)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleVerifyArtifact(cmd *cobra.Command, pubKeyFile, artifactFile, signatureFile string) error {
|
||||||
|
cmd.Println("🔍 Verifying artifact...")
|
||||||
|
|
||||||
|
// Read artifact public key
|
||||||
|
pubKeyPEM, err := os.ReadFile(pubKeyFile)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("read public key file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
publicKey, err := reposign.ParseArtifactPubKey(pubKeyPEM)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to parse artifact public key: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read artifact data
|
||||||
|
artifactData, err := os.ReadFile(artifactFile)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("read artifact file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read signature
|
||||||
|
sigBytes, err := os.ReadFile(signatureFile)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("read signature file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
signature, err := reposign.ParseSignature(sigBytes)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to parse signature: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate artifact
|
||||||
|
if err := reposign.ValidateArtifact([]reposign.PublicKey{publicKey}, artifactData, *signature); err != nil {
|
||||||
|
return fmt.Errorf("artifact verification failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.Println("✅ Artifact signature is valid")
|
||||||
|
cmd.Printf("Artifact: %s\n", artifactFile)
|
||||||
|
cmd.Printf("Signed by key: %s\n", signature.KeyID)
|
||||||
|
cmd.Printf("Signature timestamp: %s\n", signature.Timestamp.Format("2006-01-02 15:04:05 MST"))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleVerifyArtifactKey(cmd *cobra.Command, artifactKeyFile, rootKeyFile, signatureFile, revocationFile string) error {
|
||||||
|
cmd.Println("🔍 Verifying artifact key...")
|
||||||
|
|
||||||
|
// Read artifact key data
|
||||||
|
artifactKeyData, err := os.ReadFile(artifactKeyFile)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("read artifact key file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read root public key(s)
|
||||||
|
rootKeyData, err := os.ReadFile(rootKeyFile)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("read root key file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rootPublicKeys, err := parseRootPublicKeys(rootKeyData)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to parse root public key(s): %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read signature
|
||||||
|
sigBytes, err := os.ReadFile(signatureFile)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("read signature file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
signature, err := reposign.ParseSignature(sigBytes)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to parse signature: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read optional revocation list
|
||||||
|
var revocationList *reposign.RevocationList
|
||||||
|
if revocationFile != "" {
|
||||||
|
revData, err := os.ReadFile(revocationFile)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("read revocation file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
revocationList, err = reposign.ParseRevocationList(revData)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to parse revocation list: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate artifact key(s)
|
||||||
|
validKeys, err := reposign.ValidateArtifactKeys(rootPublicKeys, artifactKeyData, *signature, revocationList)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("artifact key verification failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.Println("✅ Artifact key(s) verified successfully")
|
||||||
|
cmd.Printf("Signed by root key: %s\n", signature.KeyID)
|
||||||
|
cmd.Printf("Signature timestamp: %s\n", signature.Timestamp.Format("2006-01-02 15:04:05 MST"))
|
||||||
|
cmd.Printf("\nValid artifact keys (%d):\n", len(validKeys))
|
||||||
|
for i, key := range validKeys {
|
||||||
|
cmd.Printf(" [%d] Key ID: %s\n", i+1, key.Metadata.ID)
|
||||||
|
cmd.Printf(" Created: %s\n", key.Metadata.CreatedAt.Format("2006-01-02 15:04:05 MST"))
|
||||||
|
if !key.Metadata.ExpiresAt.IsZero() {
|
||||||
|
cmd.Printf(" Expires: %s\n", key.Metadata.ExpiresAt.Format("2006-01-02 15:04:05 MST"))
|
||||||
|
} else {
|
||||||
|
cmd.Printf(" Expires: Never\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseRootPublicKeys parses a root public key from PEM data
|
||||||
|
func parseRootPublicKeys(data []byte) ([]reposign.PublicKey, error) {
|
||||||
|
key, err := reposign.ParseRootPublicKey(data)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return []reposign.PublicKey{key}, nil
|
||||||
|
}
|
||||||
21
client/cmd/signer/main.go
Normal file
21
client/cmd/signer/main.go
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
var rootCmd = &cobra.Command{
|
||||||
|
Use: "signer",
|
||||||
|
Short: "A CLI tool for managing cryptographic keys and artifacts",
|
||||||
|
Long: `signer is a command-line tool that helps you manage
|
||||||
|
root keys, artifact keys, and revocation lists securely.`,
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
if err := rootCmd.Execute(); err != nil {
|
||||||
|
rootCmd.Println(err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
220
client/cmd/signer/revocation.go
Normal file
220
client/cmd/signer/revocation.go
Normal file
@@ -0,0 +1,220 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
|
"github.com/netbirdio/netbird/client/internal/updater/reposign"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
defaultRevocationListExpiration = 365 * 24 * time.Hour // 1 year
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
keyID string
|
||||||
|
revocationListFile string
|
||||||
|
privateRootKeyFile string
|
||||||
|
publicRootKeyFile string
|
||||||
|
signatureFile string
|
||||||
|
expirationDuration time.Duration
|
||||||
|
)
|
||||||
|
|
||||||
|
var createRevocationListCmd = &cobra.Command{
|
||||||
|
Use: "create-revocation-list",
|
||||||
|
Short: "Create a new revocation list signed by the private root key",
|
||||||
|
SilenceUsage: true,
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
return handleCreateRevocationList(cmd, revocationListFile, privateRootKeyFile)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var extendRevocationListCmd = &cobra.Command{
|
||||||
|
Use: "extend-revocation-list",
|
||||||
|
Short: "Extend an existing revocation list with a given key ID",
|
||||||
|
SilenceUsage: true,
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
return handleExtendRevocationList(cmd, keyID, revocationListFile, privateRootKeyFile)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var verifyRevocationListCmd = &cobra.Command{
|
||||||
|
Use: "verify-revocation-list",
|
||||||
|
Short: "Verify a revocation list signature using the public root key",
|
||||||
|
SilenceUsage: true,
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
return handleVerifyRevocationList(cmd, revocationListFile, signatureFile, publicRootKeyFile)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
rootCmd.AddCommand(createRevocationListCmd)
|
||||||
|
rootCmd.AddCommand(extendRevocationListCmd)
|
||||||
|
rootCmd.AddCommand(verifyRevocationListCmd)
|
||||||
|
|
||||||
|
createRevocationListCmd.Flags().StringVar(&revocationListFile, "revocation-list-file", "", "Path to the existing revocation list file")
|
||||||
|
createRevocationListCmd.Flags().StringVar(&privateRootKeyFile, "private-root-key", "", "Path to the private root key PEM file")
|
||||||
|
createRevocationListCmd.Flags().DurationVar(&expirationDuration, "expiration", defaultRevocationListExpiration, "Expiration duration for the revocation list (e.g., 8760h for 1 year)")
|
||||||
|
if err := createRevocationListCmd.MarkFlagRequired("revocation-list-file"); err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
if err := createRevocationListCmd.MarkFlagRequired("private-root-key"); err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
extendRevocationListCmd.Flags().StringVar(&keyID, "key-id", "", "ID of the key to extend the revocation list for")
|
||||||
|
extendRevocationListCmd.Flags().StringVar(&revocationListFile, "revocation-list-file", "", "Path to the existing revocation list file")
|
||||||
|
extendRevocationListCmd.Flags().StringVar(&privateRootKeyFile, "private-root-key", "", "Path to the private root key PEM file")
|
||||||
|
extendRevocationListCmd.Flags().DurationVar(&expirationDuration, "expiration", defaultRevocationListExpiration, "Expiration duration for the revocation list (e.g., 8760h for 1 year)")
|
||||||
|
if err := extendRevocationListCmd.MarkFlagRequired("key-id"); err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
if err := extendRevocationListCmd.MarkFlagRequired("revocation-list-file"); err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
if err := extendRevocationListCmd.MarkFlagRequired("private-root-key"); err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
verifyRevocationListCmd.Flags().StringVar(&revocationListFile, "revocation-list-file", "", "Path to the revocation list file")
|
||||||
|
verifyRevocationListCmd.Flags().StringVar(&signatureFile, "signature-file", "", "Path to the signature file")
|
||||||
|
verifyRevocationListCmd.Flags().StringVar(&publicRootKeyFile, "public-root-key", "", "Path to the public root key PEM file")
|
||||||
|
if err := verifyRevocationListCmd.MarkFlagRequired("revocation-list-file"); err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
if err := verifyRevocationListCmd.MarkFlagRequired("signature-file"); err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
if err := verifyRevocationListCmd.MarkFlagRequired("public-root-key"); err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleCreateRevocationList(cmd *cobra.Command, revocationListFile string, privateRootKeyFile string) error {
|
||||||
|
privKeyPEM, err := os.ReadFile(privateRootKeyFile)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to read private root key file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
privateRootKey, err := reposign.ParseRootKey(privKeyPEM)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to parse private root key: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rlBytes, sigBytes, err := reposign.CreateRevocationList(*privateRootKey, expirationDuration)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create revocation list: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := writeOutputFiles(revocationListFile, revocationListFile+".sig", rlBytes, sigBytes); err != nil {
|
||||||
|
return fmt.Errorf("failed to write output files: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.Println("✅ Revocation list created successfully")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleExtendRevocationList(cmd *cobra.Command, keyID, revocationListFile, privateRootKeyFile string) error {
|
||||||
|
privKeyPEM, err := os.ReadFile(privateRootKeyFile)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to read private root key file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
privateRootKey, err := reposign.ParseRootKey(privKeyPEM)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to parse private root key: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rlBytes, err := os.ReadFile(revocationListFile)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to read revocation list file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rl, err := reposign.ParseRevocationList(rlBytes)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to parse revocation list: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
kid, err := reposign.ParseKeyID(keyID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("invalid key ID: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
newRLBytes, sigBytes, err := reposign.ExtendRevocationList(*privateRootKey, *rl, kid, expirationDuration)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to extend revocation list: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := writeOutputFiles(revocationListFile, revocationListFile+".sig", newRLBytes, sigBytes); err != nil {
|
||||||
|
return fmt.Errorf("failed to write output files: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.Println("✅ Revocation list extended successfully")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleVerifyRevocationList(cmd *cobra.Command, revocationListFile, signatureFile, publicRootKeyFile string) error {
|
||||||
|
// Read revocation list file
|
||||||
|
rlBytes, err := os.ReadFile(revocationListFile)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to read revocation list file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read signature file
|
||||||
|
sigBytes, err := os.ReadFile(signatureFile)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to read signature file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read public root key file
|
||||||
|
pubKeyPEM, err := os.ReadFile(publicRootKeyFile)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to read public root key file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse public root key
|
||||||
|
publicKey, err := reposign.ParseRootPublicKey(pubKeyPEM)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to parse public root key: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse signature
|
||||||
|
signature, err := reposign.ParseSignature(sigBytes)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to parse signature: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate revocation list
|
||||||
|
rl, err := reposign.ValidateRevocationList([]reposign.PublicKey{publicKey}, rlBytes, *signature)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to validate revocation list: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Display results
|
||||||
|
cmd.Println("✅ Revocation list signature is valid")
|
||||||
|
cmd.Printf("Last Updated: %s\n", rl.LastUpdated.Format(time.RFC3339))
|
||||||
|
cmd.Printf("Expires At: %s\n", rl.ExpiresAt.Format(time.RFC3339))
|
||||||
|
cmd.Printf("Number of revoked keys: %d\n", len(rl.Revoked))
|
||||||
|
|
||||||
|
if len(rl.Revoked) > 0 {
|
||||||
|
cmd.Println("\nRevoked Keys:")
|
||||||
|
for keyID, revokedTime := range rl.Revoked {
|
||||||
|
cmd.Printf(" - %s (revoked at: %s)\n", keyID, revokedTime.Format(time.RFC3339))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeOutputFiles(rlPath, sigPath string, rlBytes, sigBytes []byte) error {
|
||||||
|
if err := os.WriteFile(rlPath, rlBytes, 0o600); err != nil {
|
||||||
|
return fmt.Errorf("failed to write revocation list file: %w", err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(sigPath, sigBytes, 0o600); err != nil {
|
||||||
|
return fmt.Errorf("failed to write signature file: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
74
client/cmd/signer/rootkey.go
Normal file
74
client/cmd/signer/rootkey.go
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
|
"github.com/netbirdio/netbird/client/internal/updater/reposign"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
privKeyFile string
|
||||||
|
pubKeyFile string
|
||||||
|
rootExpiration time.Duration
|
||||||
|
)
|
||||||
|
|
||||||
|
var createRootKeyCmd = &cobra.Command{
|
||||||
|
Use: "create-root-key",
|
||||||
|
Short: "Create a new root key pair",
|
||||||
|
Long: `Create a new root key pair and specify an expiration time for it.`,
|
||||||
|
SilenceUsage: true,
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
// Validate expiration
|
||||||
|
if rootExpiration <= 0 {
|
||||||
|
return fmt.Errorf("--expiration must be a positive duration (e.g., 720h, 365d, 8760h)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run main logic
|
||||||
|
if err := handleGenerateRootKey(cmd, privKeyFile, pubKeyFile, rootExpiration); err != nil {
|
||||||
|
return fmt.Errorf("failed to generate root key: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
rootCmd.AddCommand(createRootKeyCmd)
|
||||||
|
createRootKeyCmd.Flags().StringVar(&privKeyFile, "priv-key-file", "", "Path to output private key file")
|
||||||
|
createRootKeyCmd.Flags().StringVar(&pubKeyFile, "pub-key-file", "", "Path to output public key file")
|
||||||
|
createRootKeyCmd.Flags().DurationVar(&rootExpiration, "expiration", 0, "Expiration time for the root key (e.g., 720h,)")
|
||||||
|
|
||||||
|
if err := createRootKeyCmd.MarkFlagRequired("priv-key-file"); err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
if err := createRootKeyCmd.MarkFlagRequired("pub-key-file"); err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
if err := createRootKeyCmd.MarkFlagRequired("expiration"); err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleGenerateRootKey(cmd *cobra.Command, privKeyFile, pubKeyFile string, expiration time.Duration) error {
|
||||||
|
rk, privPEM, pubPEM, err := reposign.GenerateRootKey(expiration)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("generate root key: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write private key
|
||||||
|
if err := os.WriteFile(privKeyFile, privPEM, 0o600); err != nil {
|
||||||
|
return fmt.Errorf("write private key file (%s): %w", privKeyFile, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write public key
|
||||||
|
if err := os.WriteFile(pubKeyFile, pubPEM, 0o600); err != nil {
|
||||||
|
return fmt.Errorf("write public key file (%s): %w", pubKeyFile, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.Printf("%s\n\n", rk.String())
|
||||||
|
cmd.Printf("✅ Root key pair generated successfully.\n")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -14,7 +14,9 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"syscall"
|
"syscall"
|
||||||
|
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
"golang.org/x/crypto/ssh"
|
||||||
|
|
||||||
"github.com/netbirdio/netbird/client/internal"
|
"github.com/netbirdio/netbird/client/internal"
|
||||||
sshclient "github.com/netbirdio/netbird/client/ssh/client"
|
sshclient "github.com/netbirdio/netbird/client/ssh/client"
|
||||||
@@ -34,6 +36,7 @@ const (
|
|||||||
enableSSHLocalPortForwardFlag = "enable-ssh-local-port-forwarding"
|
enableSSHLocalPortForwardFlag = "enable-ssh-local-port-forwarding"
|
||||||
enableSSHRemotePortForwardFlag = "enable-ssh-remote-port-forwarding"
|
enableSSHRemotePortForwardFlag = "enable-ssh-remote-port-forwarding"
|
||||||
disableSSHAuthFlag = "disable-ssh-auth"
|
disableSSHAuthFlag = "disable-ssh-auth"
|
||||||
|
sshJWTCacheTTLFlag = "ssh-jwt-cache-ttl"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -47,6 +50,8 @@ var (
|
|||||||
knownHostsFile string
|
knownHostsFile string
|
||||||
identityFile string
|
identityFile string
|
||||||
skipCachedToken bool
|
skipCachedToken bool
|
||||||
|
requestPTY bool
|
||||||
|
sshNoBrowser bool
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -56,6 +61,7 @@ var (
|
|||||||
enableSSHLocalPortForward bool
|
enableSSHLocalPortForward bool
|
||||||
enableSSHRemotePortForward bool
|
enableSSHRemotePortForward bool
|
||||||
disableSSHAuth bool
|
disableSSHAuth bool
|
||||||
|
sshJWTCacheTTL int
|
||||||
)
|
)
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
@@ -65,14 +71,18 @@ func init() {
|
|||||||
upCmd.PersistentFlags().BoolVar(&enableSSHLocalPortForward, enableSSHLocalPortForwardFlag, false, "Enable local port forwarding for SSH server")
|
upCmd.PersistentFlags().BoolVar(&enableSSHLocalPortForward, enableSSHLocalPortForwardFlag, false, "Enable local port forwarding for SSH server")
|
||||||
upCmd.PersistentFlags().BoolVar(&enableSSHRemotePortForward, enableSSHRemotePortForwardFlag, false, "Enable remote port forwarding for SSH server")
|
upCmd.PersistentFlags().BoolVar(&enableSSHRemotePortForward, enableSSHRemotePortForwardFlag, false, "Enable remote port forwarding for SSH server")
|
||||||
upCmd.PersistentFlags().BoolVar(&disableSSHAuth, disableSSHAuthFlag, false, "Disable SSH authentication")
|
upCmd.PersistentFlags().BoolVar(&disableSSHAuth, disableSSHAuthFlag, false, "Disable SSH authentication")
|
||||||
|
upCmd.PersistentFlags().IntVar(&sshJWTCacheTTL, sshJWTCacheTTLFlag, 0, "SSH JWT token cache TTL in seconds (0=disabled)")
|
||||||
|
|
||||||
sshCmd.PersistentFlags().IntVarP(&port, "port", "p", sshserver.DefaultSSHPort, "Remote SSH port")
|
sshCmd.PersistentFlags().IntVarP(&port, "port", "p", sshserver.DefaultSSHPort, "Remote SSH port")
|
||||||
sshCmd.PersistentFlags().StringVarP(&username, "user", "u", "", sshUsernameDesc)
|
sshCmd.PersistentFlags().StringVarP(&username, "user", "u", "", sshUsernameDesc)
|
||||||
sshCmd.PersistentFlags().StringVar(&username, "login", "", sshUsernameDesc+" (alias for --user)")
|
sshCmd.PersistentFlags().StringVar(&username, "login", "", sshUsernameDesc+" (alias for --user)")
|
||||||
|
sshCmd.PersistentFlags().BoolVarP(&requestPTY, "tty", "t", false, "Force pseudo-terminal allocation")
|
||||||
sshCmd.PersistentFlags().BoolVar(&strictHostKeyChecking, "strict-host-key-checking", true, "Enable strict host key checking (default: true)")
|
sshCmd.PersistentFlags().BoolVar(&strictHostKeyChecking, "strict-host-key-checking", true, "Enable strict host key checking (default: true)")
|
||||||
sshCmd.PersistentFlags().StringVarP(&knownHostsFile, "known-hosts", "o", "", "Path to known_hosts file (default: ~/.ssh/known_hosts)")
|
sshCmd.PersistentFlags().StringVarP(&knownHostsFile, "known-hosts", "o", "", "Path to known_hosts file (default: ~/.ssh/known_hosts)")
|
||||||
sshCmd.PersistentFlags().StringVarP(&identityFile, "identity", "i", "", "Path to SSH private key file")
|
sshCmd.PersistentFlags().StringVarP(&identityFile, "identity", "i", "", "Path to SSH private key file (deprecated)")
|
||||||
|
_ = sshCmd.PersistentFlags().MarkDeprecated("identity", "this flag is no longer used")
|
||||||
sshCmd.PersistentFlags().BoolVar(&skipCachedToken, "no-cache", false, "Skip cached JWT token and force fresh authentication")
|
sshCmd.PersistentFlags().BoolVar(&skipCachedToken, "no-cache", false, "Skip cached JWT token and force fresh authentication")
|
||||||
|
sshCmd.PersistentFlags().BoolVar(&sshNoBrowser, noBrowserFlag, false, noBrowserDesc)
|
||||||
|
|
||||||
sshCmd.PersistentFlags().StringArrayP("L", "L", []string{}, "Local port forwarding [bind_address:]port:host:hostport")
|
sshCmd.PersistentFlags().StringArrayP("L", "L", []string{}, "Local port forwarding [bind_address:]port:host:hostport")
|
||||||
sshCmd.PersistentFlags().StringArrayP("R", "R", []string{}, "Remote port forwarding [bind_address:]port:host:hostport")
|
sshCmd.PersistentFlags().StringArrayP("R", "R", []string{}, "Remote port forwarding [bind_address:]port:host:hostport")
|
||||||
@@ -97,9 +107,9 @@ SSH Options:
|
|||||||
-p, --port int Remote SSH port (default 22)
|
-p, --port int Remote SSH port (default 22)
|
||||||
-u, --user string SSH username
|
-u, --user string SSH username
|
||||||
--login string SSH username (alias for --user)
|
--login string SSH username (alias for --user)
|
||||||
|
-t, --tty Force pseudo-terminal allocation
|
||||||
--strict-host-key-checking Enable strict host key checking (default: true)
|
--strict-host-key-checking Enable strict host key checking (default: true)
|
||||||
-o, --known-hosts string Path to known_hosts file
|
-o, --known-hosts string Path to known_hosts file
|
||||||
-i, --identity string Path to SSH private key file
|
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
netbird ssh peer-hostname
|
netbird ssh peer-hostname
|
||||||
@@ -107,8 +117,10 @@ Examples:
|
|||||||
netbird ssh --login root peer-hostname
|
netbird ssh --login root peer-hostname
|
||||||
netbird ssh peer-hostname ls -la
|
netbird ssh peer-hostname ls -la
|
||||||
netbird ssh peer-hostname whoami
|
netbird ssh peer-hostname whoami
|
||||||
netbird ssh -L 8080:localhost:80 peer-hostname # Local port forwarding
|
netbird ssh -t peer-hostname tmux # Force PTY for tmux/screen
|
||||||
netbird ssh -R 9090:localhost:3000 peer-hostname # Remote port forwarding
|
netbird ssh -t peer-hostname sudo -i # Force PTY for interactive sudo
|
||||||
|
netbird ssh -L 8080:localhost:80 peer-hostname # Local port forwarding
|
||||||
|
netbird ssh -R 9090:localhost:3000 peer-hostname # Remote port forwarding
|
||||||
netbird ssh -L "*:8080:localhost:80" peer-hostname # Bind to all interfaces
|
netbird ssh -L "*:8080:localhost:80" peer-hostname # Bind to all interfaces
|
||||||
netbird ssh -L 8080:/tmp/socket peer-hostname # Unix socket forwarding`,
|
netbird ssh -L 8080:/tmp/socket peer-hostname # Unix socket forwarding`,
|
||||||
DisableFlagParsing: true,
|
DisableFlagParsing: true,
|
||||||
@@ -143,10 +155,10 @@ func sshFn(cmd *cobra.Command, args []string) error {
|
|||||||
signal.Notify(sig, syscall.SIGTERM, syscall.SIGINT)
|
signal.Notify(sig, syscall.SIGTERM, syscall.SIGINT)
|
||||||
sshctx, cancel := context.WithCancel(ctx)
|
sshctx, cancel := context.WithCancel(ctx)
|
||||||
|
|
||||||
|
errCh := make(chan error, 1)
|
||||||
go func() {
|
go func() {
|
||||||
if err := runSSH(sshctx, host, cmd); err != nil {
|
if err := runSSH(sshctx, host, cmd); err != nil {
|
||||||
cmd.Printf("Error: %v\n", err)
|
errCh <- err
|
||||||
os.Exit(1)
|
|
||||||
}
|
}
|
||||||
cancel()
|
cancel()
|
||||||
}()
|
}()
|
||||||
@@ -154,6 +166,10 @@ func sshFn(cmd *cobra.Command, args []string) error {
|
|||||||
select {
|
select {
|
||||||
case <-sig:
|
case <-sig:
|
||||||
cancel()
|
cancel()
|
||||||
|
<-sshctx.Done()
|
||||||
|
return nil
|
||||||
|
case err := <-errCh:
|
||||||
|
return err
|
||||||
case <-sshctx.Done():
|
case <-sshctx.Done():
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -171,6 +187,21 @@ func getEnvOrDefault(flagName, defaultValue string) string {
|
|||||||
return defaultValue
|
return defaultValue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// getBoolEnvOrDefault checks for boolean environment variables with WT_ and NB_ prefixes
|
||||||
|
func getBoolEnvOrDefault(flagName string, defaultValue bool) bool {
|
||||||
|
if envValue := os.Getenv("WT_" + flagName); envValue != "" {
|
||||||
|
if parsed, err := strconv.ParseBool(envValue); err == nil {
|
||||||
|
return parsed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if envValue := os.Getenv("NB_" + flagName); envValue != "" {
|
||||||
|
if parsed, err := strconv.ParseBool(envValue); err == nil {
|
||||||
|
return parsed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return defaultValue
|
||||||
|
}
|
||||||
|
|
||||||
// resetSSHGlobals sets SSH globals to their default values
|
// resetSSHGlobals sets SSH globals to their default values
|
||||||
func resetSSHGlobals() {
|
func resetSSHGlobals() {
|
||||||
port = sshserver.DefaultSSHPort
|
port = sshserver.DefaultSSHPort
|
||||||
@@ -182,6 +213,7 @@ func resetSSHGlobals() {
|
|||||||
strictHostKeyChecking = true
|
strictHostKeyChecking = true
|
||||||
knownHostsFile = ""
|
knownHostsFile = ""
|
||||||
identityFile = ""
|
identityFile = ""
|
||||||
|
sshNoBrowser = false
|
||||||
}
|
}
|
||||||
|
|
||||||
// parseCustomSSHFlags extracts -L, -R flags and returns filtered args
|
// parseCustomSSHFlags extracts -L, -R flags and returns filtered args
|
||||||
@@ -351,10 +383,12 @@ type sshFlags struct {
|
|||||||
Port int
|
Port int
|
||||||
Username string
|
Username string
|
||||||
Login string
|
Login string
|
||||||
|
RequestPTY bool
|
||||||
StrictHostKeyChecking bool
|
StrictHostKeyChecking bool
|
||||||
KnownHostsFile string
|
KnownHostsFile string
|
||||||
IdentityFile string
|
IdentityFile string
|
||||||
SkipCachedToken bool
|
SkipCachedToken bool
|
||||||
|
NoBrowser bool
|
||||||
ConfigPath string
|
ConfigPath string
|
||||||
LogLevel string
|
LogLevel string
|
||||||
LocalForwards []string
|
LocalForwards []string
|
||||||
@@ -366,6 +400,7 @@ type sshFlags struct {
|
|||||||
func createSSHFlagSet() (*flag.FlagSet, *sshFlags) {
|
func createSSHFlagSet() (*flag.FlagSet, *sshFlags) {
|
||||||
defaultConfigPath := getEnvOrDefault("CONFIG", configPath)
|
defaultConfigPath := getEnvOrDefault("CONFIG", configPath)
|
||||||
defaultLogLevel := getEnvOrDefault("LOG_LEVEL", logLevel)
|
defaultLogLevel := getEnvOrDefault("LOG_LEVEL", logLevel)
|
||||||
|
defaultNoBrowser := getBoolEnvOrDefault("NO_BROWSER", false)
|
||||||
|
|
||||||
fs := flag.NewFlagSet("ssh-flags", flag.ContinueOnError)
|
fs := flag.NewFlagSet("ssh-flags", flag.ContinueOnError)
|
||||||
fs.SetOutput(nil)
|
fs.SetOutput(nil)
|
||||||
@@ -373,22 +408,25 @@ func createSSHFlagSet() (*flag.FlagSet, *sshFlags) {
|
|||||||
flags := &sshFlags{}
|
flags := &sshFlags{}
|
||||||
|
|
||||||
fs.IntVar(&flags.Port, "p", sshserver.DefaultSSHPort, "SSH port")
|
fs.IntVar(&flags.Port, "p", sshserver.DefaultSSHPort, "SSH port")
|
||||||
fs.Int("port", sshserver.DefaultSSHPort, "SSH port")
|
fs.IntVar(&flags.Port, "port", sshserver.DefaultSSHPort, "SSH port")
|
||||||
fs.StringVar(&flags.Username, "u", "", sshUsernameDesc)
|
fs.StringVar(&flags.Username, "u", "", sshUsernameDesc)
|
||||||
fs.String("user", "", sshUsernameDesc)
|
fs.StringVar(&flags.Username, "user", "", sshUsernameDesc)
|
||||||
fs.StringVar(&flags.Login, "login", "", sshUsernameDesc+" (alias for --user)")
|
fs.StringVar(&flags.Login, "login", "", sshUsernameDesc+" (alias for --user)")
|
||||||
|
fs.BoolVar(&flags.RequestPTY, "t", false, "Force pseudo-terminal allocation")
|
||||||
|
fs.BoolVar(&flags.RequestPTY, "tty", false, "Force pseudo-terminal allocation")
|
||||||
|
|
||||||
fs.BoolVar(&flags.StrictHostKeyChecking, "strict-host-key-checking", true, "Enable strict host key checking")
|
fs.BoolVar(&flags.StrictHostKeyChecking, "strict-host-key-checking", true, "Enable strict host key checking")
|
||||||
fs.StringVar(&flags.KnownHostsFile, "o", "", "Path to known_hosts file")
|
fs.StringVar(&flags.KnownHostsFile, "o", "", "Path to known_hosts file")
|
||||||
fs.String("known-hosts", "", "Path to known_hosts file")
|
fs.StringVar(&flags.KnownHostsFile, "known-hosts", "", "Path to known_hosts file")
|
||||||
fs.StringVar(&flags.IdentityFile, "i", "", "Path to SSH private key file")
|
fs.StringVar(&flags.IdentityFile, "i", "", "Path to SSH private key file")
|
||||||
fs.String("identity", "", "Path to SSH private key file")
|
fs.StringVar(&flags.IdentityFile, "identity", "", "Path to SSH private key file")
|
||||||
fs.BoolVar(&flags.SkipCachedToken, "no-cache", false, "Skip cached JWT token and force fresh authentication")
|
fs.BoolVar(&flags.SkipCachedToken, "no-cache", false, "Skip cached JWT token and force fresh authentication")
|
||||||
|
fs.BoolVar(&flags.NoBrowser, "no-browser", defaultNoBrowser, noBrowserDesc)
|
||||||
|
|
||||||
fs.StringVar(&flags.ConfigPath, "c", defaultConfigPath, "Netbird config file location")
|
fs.StringVar(&flags.ConfigPath, "c", defaultConfigPath, "Netbird config file location")
|
||||||
fs.String("config", defaultConfigPath, "Netbird config file location")
|
fs.StringVar(&flags.ConfigPath, "config", defaultConfigPath, "Netbird config file location")
|
||||||
fs.StringVar(&flags.LogLevel, "l", defaultLogLevel, "sets Netbird log level")
|
fs.StringVar(&flags.LogLevel, "l", defaultLogLevel, "sets Netbird log level")
|
||||||
fs.String("log-level", defaultLogLevel, "sets Netbird log level")
|
fs.StringVar(&flags.LogLevel, "log-level", defaultLogLevel, "sets Netbird log level")
|
||||||
|
|
||||||
return fs, flags
|
return fs, flags
|
||||||
}
|
}
|
||||||
@@ -409,7 +447,10 @@ func validateSSHArgsWithoutFlagParsing(_ *cobra.Command, args []string) error {
|
|||||||
fs, flags := createSSHFlagSet()
|
fs, flags := createSSHFlagSet()
|
||||||
|
|
||||||
if err := fs.Parse(filteredArgs); err != nil {
|
if err := fs.Parse(filteredArgs); err != nil {
|
||||||
return parseHostnameAndCommand(filteredArgs)
|
if errors.Is(err, flag.ErrHelp) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
remaining := fs.Args()
|
remaining := fs.Args()
|
||||||
@@ -424,10 +465,12 @@ func validateSSHArgsWithoutFlagParsing(_ *cobra.Command, args []string) error {
|
|||||||
username = flags.Login
|
username = flags.Login
|
||||||
}
|
}
|
||||||
|
|
||||||
|
requestPTY = flags.RequestPTY
|
||||||
strictHostKeyChecking = flags.StrictHostKeyChecking
|
strictHostKeyChecking = flags.StrictHostKeyChecking
|
||||||
knownHostsFile = flags.KnownHostsFile
|
knownHostsFile = flags.KnownHostsFile
|
||||||
identityFile = flags.IdentityFile
|
identityFile = flags.IdentityFile
|
||||||
skipCachedToken = flags.SkipCachedToken
|
skipCachedToken = flags.SkipCachedToken
|
||||||
|
sshNoBrowser = flags.NoBrowser
|
||||||
|
|
||||||
if flags.ConfigPath != getEnvOrDefault("CONFIG", configPath) {
|
if flags.ConfigPath != getEnvOrDefault("CONFIG", configPath) {
|
||||||
configPath = flags.ConfigPath
|
configPath = flags.ConfigPath
|
||||||
@@ -480,13 +523,14 @@ func parseHostnameAndCommand(args []string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func runSSH(ctx context.Context, addr string, cmd *cobra.Command) error {
|
func runSSH(ctx context.Context, addr string, cmd *cobra.Command) error {
|
||||||
target := fmt.Sprintf("%s:%d", addr, port)
|
target := net.JoinHostPort(strings.Trim(addr, "[]"), strconv.Itoa(port))
|
||||||
c, err := sshclient.Dial(ctx, target, username, sshclient.DialOptions{
|
c, err := sshclient.Dial(ctx, target, username, sshclient.DialOptions{
|
||||||
KnownHostsFile: knownHostsFile,
|
KnownHostsFile: knownHostsFile,
|
||||||
IdentityFile: identityFile,
|
IdentityFile: identityFile,
|
||||||
DaemonAddr: daemonAddr,
|
DaemonAddr: daemonAddr,
|
||||||
SkipCachedToken: skipCachedToken,
|
SkipCachedToken: skipCachedToken,
|
||||||
InsecureSkipVerify: !strictHostKeyChecking,
|
InsecureSkipVerify: !strictHostKeyChecking,
|
||||||
|
NoBrowser: sshNoBrowser,
|
||||||
})
|
})
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -520,10 +564,29 @@ func runSSH(ctx context.Context, addr string, cmd *cobra.Command) error {
|
|||||||
|
|
||||||
// executeSSHCommand executes a command over SSH.
|
// executeSSHCommand executes a command over SSH.
|
||||||
func executeSSHCommand(ctx context.Context, c *sshclient.Client, command string) error {
|
func executeSSHCommand(ctx context.Context, c *sshclient.Client, command string) error {
|
||||||
if err := c.ExecuteCommandWithIO(ctx, command); err != nil {
|
var err error
|
||||||
|
if requestPTY {
|
||||||
|
err = c.ExecuteCommandWithPTY(ctx, command)
|
||||||
|
} else {
|
||||||
|
err = c.ExecuteCommandWithIO(ctx, command)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
|
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var exitErr *ssh.ExitError
|
||||||
|
if errors.As(err, &exitErr) {
|
||||||
|
os.Exit(exitErr.ExitStatus())
|
||||||
|
}
|
||||||
|
|
||||||
|
var exitMissingErr *ssh.ExitMissingError
|
||||||
|
if errors.As(err, &exitMissingErr) {
|
||||||
|
log.Debugf("Remote command exited without exit status: %v", err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
return fmt.Errorf("execute command: %w", err)
|
return fmt.Errorf("execute command: %w", err)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
@@ -535,6 +598,13 @@ func openSSHTerminal(ctx context.Context, c *sshclient.Client) error {
|
|||||||
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
|
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var exitMissingErr *ssh.ExitMissingError
|
||||||
|
if errors.As(err, &exitMissingErr) {
|
||||||
|
log.Debugf("Remote terminal exited without exit status: %v", err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
return fmt.Errorf("open terminal: %w", err)
|
return fmt.Errorf("open terminal: %w", err)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
@@ -564,7 +634,11 @@ func parseAndStartLocalForward(ctx context.Context, c *sshclient.Client, forward
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd.Printf("Local port forwarding: %s -> %s\n", localAddr, remoteAddr)
|
if err := validateDestinationPort(remoteAddr); err != nil {
|
||||||
|
return fmt.Errorf("invalid remote address: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Debugf("Local port forwarding: %s -> %s", localAddr, remoteAddr)
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
if err := c.LocalPortForward(ctx, localAddr, remoteAddr); err != nil && !errors.Is(err, context.Canceled) {
|
if err := c.LocalPortForward(ctx, localAddr, remoteAddr); err != nil && !errors.Is(err, context.Canceled) {
|
||||||
@@ -582,7 +656,11 @@ func parseAndStartRemoteForward(ctx context.Context, c *sshclient.Client, forwar
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd.Printf("Remote port forwarding: %s -> %s\n", remoteAddr, localAddr)
|
if err := validateDestinationPort(localAddr); err != nil {
|
||||||
|
return fmt.Errorf("invalid local address: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Debugf("Remote port forwarding: %s -> %s", remoteAddr, localAddr)
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
if err := c.RemotePortForward(ctx, remoteAddr, localAddr); err != nil && !errors.Is(err, context.Canceled) {
|
if err := c.RemotePortForward(ctx, remoteAddr, localAddr); err != nil && !errors.Is(err, context.Canceled) {
|
||||||
@@ -593,6 +671,35 @@ func parseAndStartRemoteForward(ctx context.Context, c *sshclient.Client, forwar
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// validateDestinationPort checks that the destination address has a valid port.
|
||||||
|
// Port 0 is only valid for bind addresses (where the OS picks an available port),
|
||||||
|
// not for destination addresses where we need to connect.
|
||||||
|
func validateDestinationPort(addr string) error {
|
||||||
|
if strings.HasPrefix(addr, "/") || strings.HasPrefix(addr, "./") {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
_, portStr, err := net.SplitHostPort(addr)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("parse address %s: %w", addr, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
port, err := strconv.Atoi(portStr)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("invalid port %s: %w", portStr, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if port == 0 {
|
||||||
|
return fmt.Errorf("port 0 is not valid for destination address")
|
||||||
|
}
|
||||||
|
|
||||||
|
if port < 0 || port > 65535 {
|
||||||
|
return fmt.Errorf("port %d out of range (1-65535)", port)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// parsePortForwardSpec parses port forward specifications like "8080:localhost:80" or "[::1]:8080:localhost:80".
|
// parsePortForwardSpec parses port forward specifications like "8080:localhost:80" or "[::1]:8080:localhost:80".
|
||||||
// Also supports Unix sockets like "8080:/tmp/socket" or "127.0.0.1:8080:/tmp/socket".
|
// Also supports Unix sockets like "8080:/tmp/socket" or "127.0.0.1:8080:/tmp/socket".
|
||||||
func parsePortForwardSpec(spec string) (string, string, error) {
|
func parsePortForwardSpec(spec string) (string, string, error) {
|
||||||
@@ -680,10 +787,10 @@ func isUnixSocket(path string) bool {
|
|||||||
return strings.HasPrefix(path, "/") || strings.HasPrefix(path, "./")
|
return strings.HasPrefix(path, "/") || strings.HasPrefix(path, "./")
|
||||||
}
|
}
|
||||||
|
|
||||||
// normalizeLocalHost converts "*" to "0.0.0.0" for binding to all interfaces.
|
// normalizeLocalHost converts "*" to "" for binding to all interfaces (dual-stack).
|
||||||
func normalizeLocalHost(host string) string {
|
func normalizeLocalHost(host string) string {
|
||||||
if host == "*" {
|
if host == "*" {
|
||||||
return "0.0.0.0"
|
return ""
|
||||||
}
|
}
|
||||||
return host
|
return host
|
||||||
}
|
}
|
||||||
@@ -702,7 +809,9 @@ func sshProxyFn(cmd *cobra.Command, args []string) error {
|
|||||||
if firstLogFile := util.FindFirstLogPath(logFiles); firstLogFile != "" && firstLogFile != defaultLogFile {
|
if firstLogFile := util.FindFirstLogPath(logFiles); firstLogFile != "" && firstLogFile != defaultLogFile {
|
||||||
logOutput = firstLogFile
|
logOutput = firstLogFile
|
||||||
}
|
}
|
||||||
if err := util.InitLog(logLevel, logOutput); err != nil {
|
|
||||||
|
proxyLogLevel := getEnvOrDefault("LOG_LEVEL", logLevel)
|
||||||
|
if err := util.InitLog(proxyLogLevel, logOutput); err != nil {
|
||||||
return fmt.Errorf("init log: %w", err)
|
return fmt.Errorf("init log: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -714,10 +823,23 @@ func sshProxyFn(cmd *cobra.Command, args []string) error {
|
|||||||
return fmt.Errorf("invalid port: %s", portStr)
|
return fmt.Errorf("invalid port: %s", portStr)
|
||||||
}
|
}
|
||||||
|
|
||||||
proxy, err := sshproxy.New(daemonAddr, host, port, cmd.ErrOrStderr())
|
// Check env var for browser setting since this command is invoked via SSH ProxyCommand
|
||||||
|
// where command-line flags cannot be passed. Default is to open browser.
|
||||||
|
noBrowser := getBoolEnvOrDefault("NO_BROWSER", false)
|
||||||
|
var browserOpener func(string) error
|
||||||
|
if !noBrowser {
|
||||||
|
browserOpener = util.OpenBrowser
|
||||||
|
}
|
||||||
|
|
||||||
|
proxy, err := sshproxy.New(daemonAddr, host, port, cmd.ErrOrStderr(), browserOpener)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("create SSH proxy: %w", err)
|
return fmt.Errorf("create SSH proxy: %w", err)
|
||||||
}
|
}
|
||||||
|
defer func() {
|
||||||
|
if err := proxy.Close(); err != nil {
|
||||||
|
log.Debugf("close SSH proxy: %v", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
if err := proxy.Connect(cmd.Context()); err != nil {
|
if err := proxy.Connect(cmd.Context()); err != nil {
|
||||||
return fmt.Errorf("SSH proxy: %w", err)
|
return fmt.Errorf("SSH proxy: %w", err)
|
||||||
@@ -736,7 +858,8 @@ var sshDetectCmd = &cobra.Command{
|
|||||||
}
|
}
|
||||||
|
|
||||||
func sshDetectFn(cmd *cobra.Command, args []string) error {
|
func sshDetectFn(cmd *cobra.Command, args []string) error {
|
||||||
if err := util.InitLog(logLevel, "console"); err != nil {
|
detectLogLevel := getEnvOrDefault("LOG_LEVEL", logLevel)
|
||||||
|
if err := util.InitLog(detectLogLevel, "console"); err != nil {
|
||||||
os.Exit(detection.ServerTypeRegular.ExitCode())
|
os.Exit(detection.ServerTypeRegular.ExitCode())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -745,15 +868,21 @@ func sshDetectFn(cmd *cobra.Command, args []string) error {
|
|||||||
|
|
||||||
port, err := strconv.Atoi(portStr)
|
port, err := strconv.Atoi(portStr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
log.Debugf("invalid port %q: %v", portStr, err)
|
||||||
os.Exit(detection.ServerTypeRegular.ExitCode())
|
os.Exit(detection.ServerTypeRegular.ExitCode())
|
||||||
}
|
}
|
||||||
|
|
||||||
dialer := &net.Dialer{Timeout: detection.Timeout}
|
ctx, cancel := context.WithTimeout(cmd.Context(), detection.DefaultTimeout)
|
||||||
serverType, err := detection.DetectSSHServerType(cmd.Context(), dialer, host, port)
|
|
||||||
|
dialer := &net.Dialer{}
|
||||||
|
serverType, err := detection.DetectSSHServerType(ctx, dialer, host, port)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
log.Debugf("SSH server detection failed: %v", err)
|
||||||
|
cancel()
|
||||||
os.Exit(detection.ServerTypeRegular.ExitCode())
|
os.Exit(detection.ServerTypeRegular.ExitCode())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
cancel()
|
||||||
os.Exit(serverType.ExitCode())
|
os.Exit(serverType.ExitCode())
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"os/user"
|
"os/user"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/pkg/sftp"
|
"github.com/pkg/sftp"
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
@@ -51,7 +52,7 @@ func sftpMainDirect(cmd *cobra.Command) error {
|
|||||||
if windowsDomain != "" {
|
if windowsDomain != "" {
|
||||||
expectedUsername = fmt.Sprintf(`%s\%s`, windowsDomain, windowsUsername)
|
expectedUsername = fmt.Sprintf(`%s\%s`, windowsDomain, windowsUsername)
|
||||||
}
|
}
|
||||||
if currentUser.Username != expectedUsername && currentUser.Username != windowsUsername {
|
if !strings.EqualFold(currentUser.Username, expectedUsername) && !strings.EqualFold(currentUser.Username, windowsUsername) {
|
||||||
cmd.PrintErrf("user switching failed\n")
|
cmd.PrintErrf("user switching failed\n")
|
||||||
os.Exit(sshserver.ExitCodeValidationFail)
|
os.Exit(sshserver.ExitCodeValidationFail)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -527,10 +527,10 @@ func TestParsePortForward(t *testing.T) {
|
|||||||
{
|
{
|
||||||
name: "wildcard bind all interfaces",
|
name: "wildcard bind all interfaces",
|
||||||
spec: "*:8080:localhost:80",
|
spec: "*:8080:localhost:80",
|
||||||
expectedLocal: "0.0.0.0:8080",
|
expectedLocal: ":8080",
|
||||||
expectedRemote: "localhost:80",
|
expectedRemote: "localhost:80",
|
||||||
expectError: false,
|
expectError: false,
|
||||||
description: "Wildcard * should bind to all interfaces (0.0.0.0)",
|
description: "Wildcard * should bind to all interfaces (dual-stack)",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "wildcard for port only",
|
name: "wildcard for port only",
|
||||||
@@ -667,3 +667,51 @@ func TestSSHCommand_ParameterIsolation(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSSHCommand_InvalidFlagRejection(t *testing.T) {
|
||||||
|
// Test that invalid flags are properly rejected and not misinterpreted as hostnames
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
args []string
|
||||||
|
description string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "invalid long flag before hostname",
|
||||||
|
args: []string{"--invalid-flag", "hostname"},
|
||||||
|
description: "Invalid flag should return parse error, not treat flag as hostname",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid short flag before hostname",
|
||||||
|
args: []string{"-x", "hostname"},
|
||||||
|
description: "Invalid short flag should return parse error",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid flag with value before hostname",
|
||||||
|
args: []string{"--invalid-option=value", "hostname"},
|
||||||
|
description: "Invalid flag with value should return parse error",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "typo in known flag",
|
||||||
|
args: []string{"--por", "2222", "hostname"},
|
||||||
|
description: "Typo in flag name should return parse error (not silently ignored)",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
// Reset global variables
|
||||||
|
host = ""
|
||||||
|
username = ""
|
||||||
|
port = 22
|
||||||
|
command = ""
|
||||||
|
|
||||||
|
err := validateSSHArgsWithoutFlagParsing(sshCmd, tt.args)
|
||||||
|
|
||||||
|
// Should return an error for invalid flags
|
||||||
|
assert.Error(t, err, tt.description)
|
||||||
|
|
||||||
|
// Should not have set host to the invalid flag
|
||||||
|
assert.NotEqual(t, tt.args[0], host, "Invalid flag should not be interpreted as hostname")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import (
|
|||||||
var (
|
var (
|
||||||
detailFlag bool
|
detailFlag bool
|
||||||
ipv4Flag bool
|
ipv4Flag bool
|
||||||
|
ipv6Flag bool
|
||||||
jsonFlag bool
|
jsonFlag bool
|
||||||
yamlFlag bool
|
yamlFlag bool
|
||||||
ipsFilter []string
|
ipsFilter []string
|
||||||
@@ -28,6 +29,7 @@ var (
|
|||||||
ipsFilterMap map[string]struct{}
|
ipsFilterMap map[string]struct{}
|
||||||
prefixNamesFilterMap map[string]struct{}
|
prefixNamesFilterMap map[string]struct{}
|
||||||
connectionTypeFilter string
|
connectionTypeFilter string
|
||||||
|
checkFlag string
|
||||||
)
|
)
|
||||||
|
|
||||||
var statusCmd = &cobra.Command{
|
var statusCmd = &cobra.Command{
|
||||||
@@ -44,11 +46,13 @@ func init() {
|
|||||||
statusCmd.PersistentFlags().BoolVar(&jsonFlag, "json", false, "display detailed status information in json format")
|
statusCmd.PersistentFlags().BoolVar(&jsonFlag, "json", false, "display detailed status information in json format")
|
||||||
statusCmd.PersistentFlags().BoolVar(&yamlFlag, "yaml", false, "display detailed status information in yaml format")
|
statusCmd.PersistentFlags().BoolVar(&yamlFlag, "yaml", false, "display detailed status information in yaml format")
|
||||||
statusCmd.PersistentFlags().BoolVar(&ipv4Flag, "ipv4", false, "display only NetBird IPv4 of this peer, e.g., --ipv4 will output 100.64.0.33")
|
statusCmd.PersistentFlags().BoolVar(&ipv4Flag, "ipv4", false, "display only NetBird IPv4 of this peer, e.g., --ipv4 will output 100.64.0.33")
|
||||||
statusCmd.MarkFlagsMutuallyExclusive("detail", "json", "yaml", "ipv4")
|
statusCmd.PersistentFlags().BoolVar(&ipv6Flag, "ipv6", false, "display only NetBird IPv6 of this peer")
|
||||||
statusCmd.PersistentFlags().StringSliceVar(&ipsFilter, "filter-by-ips", []string{}, "filters the detailed output by a list of one or more IPs, e.g., --filter-by-ips 100.64.0.100,100.64.0.200")
|
statusCmd.MarkFlagsMutuallyExclusive("detail", "json", "yaml", "ipv4", "ipv6")
|
||||||
|
statusCmd.PersistentFlags().StringSliceVar(&ipsFilter, "filter-by-ips", []string{}, "filters the detailed output by a list of one or more IPs (v4 or v6), e.g., --filter-by-ips 100.64.0.100,fd00::1")
|
||||||
statusCmd.PersistentFlags().StringSliceVar(&prefixNamesFilter, "filter-by-names", []string{}, "filters the detailed output by a list of one or more peer FQDN or hostnames, e.g., --filter-by-names peer-a,peer-b.netbird.cloud")
|
statusCmd.PersistentFlags().StringSliceVar(&prefixNamesFilter, "filter-by-names", []string{}, "filters the detailed output by a list of one or more peer FQDN or hostnames, e.g., --filter-by-names peer-a,peer-b.netbird.cloud")
|
||||||
statusCmd.PersistentFlags().StringVar(&statusFilter, "filter-by-status", "", "filters the detailed output by connection status(idle|connecting|connected), e.g., --filter-by-status connected")
|
statusCmd.PersistentFlags().StringVar(&statusFilter, "filter-by-status", "", "filters the detailed output by connection status(idle|connecting|connected), e.g., --filter-by-status connected")
|
||||||
statusCmd.PersistentFlags().StringVar(&connectionTypeFilter, "filter-by-connection-type", "", "filters the detailed output by connection type (P2P|Relayed), e.g., --filter-by-connection-type P2P")
|
statusCmd.PersistentFlags().StringVar(&connectionTypeFilter, "filter-by-connection-type", "", "filters the detailed output by connection type (P2P|Relayed), e.g., --filter-by-connection-type P2P")
|
||||||
|
statusCmd.PersistentFlags().StringVar(&checkFlag, "check", "", "run a health check and exit with code 0 on success, 1 on failure (live|ready|startup)")
|
||||||
}
|
}
|
||||||
|
|
||||||
func statusFunc(cmd *cobra.Command, args []string) error {
|
func statusFunc(cmd *cobra.Command, args []string) error {
|
||||||
@@ -56,6 +60,10 @@ func statusFunc(cmd *cobra.Command, args []string) error {
|
|||||||
|
|
||||||
cmd.SetOut(cmd.OutOrStdout())
|
cmd.SetOut(cmd.OutOrStdout())
|
||||||
|
|
||||||
|
if checkFlag != "" {
|
||||||
|
return runHealthCheck(cmd)
|
||||||
|
}
|
||||||
|
|
||||||
err := parseFilters()
|
err := parseFilters()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -68,15 +76,17 @@ func statusFunc(cmd *cobra.Command, args []string) error {
|
|||||||
|
|
||||||
ctx := internal.CtxInitState(cmd.Context())
|
ctx := internal.CtxInitState(cmd.Context())
|
||||||
|
|
||||||
resp, err := getStatus(ctx, false)
|
resp, err := getStatus(ctx, true, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
status := resp.GetStatus()
|
status := resp.GetStatus()
|
||||||
|
|
||||||
if status == string(internal.StatusNeedsLogin) || status == string(internal.StatusLoginFailed) ||
|
needsAuth := status == string(internal.StatusNeedsLogin) || status == string(internal.StatusLoginFailed) ||
|
||||||
status == string(internal.StatusSessionExpired) {
|
status == string(internal.StatusSessionExpired)
|
||||||
|
|
||||||
|
if needsAuth && !jsonFlag && !yamlFlag {
|
||||||
cmd.Printf("Daemon status: %s\n\n"+
|
cmd.Printf("Daemon status: %s\n\n"+
|
||||||
"Run UP command to log in with SSO (interactive login):\n\n"+
|
"Run UP command to log in with SSO (interactive login):\n\n"+
|
||||||
" netbird up \n\n"+
|
" netbird up \n\n"+
|
||||||
@@ -93,23 +103,41 @@ func statusFunc(cmd *cobra.Command, args []string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ipv6Flag {
|
||||||
|
ipv6 := resp.GetFullStatus().GetLocalPeerState().GetIpv6()
|
||||||
|
if ipv6 != "" {
|
||||||
|
cmd.Print(parseInterfaceIP(ipv6))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
pm := profilemanager.NewProfileManager()
|
pm := profilemanager.NewProfileManager()
|
||||||
var profName string
|
var profName string
|
||||||
if activeProf, err := pm.GetActiveProfile(); err == nil {
|
if activeProf, err := pm.GetActiveProfile(); err == nil {
|
||||||
profName = activeProf.Name
|
profName = activeProf.Name
|
||||||
}
|
}
|
||||||
|
|
||||||
var outputInformationHolder = nbstatus.ConvertToStatusOutputOverview(resp, anonymizeFlag, statusFilter, prefixNamesFilter, prefixNamesFilterMap, ipsFilterMap, connectionTypeFilter, profName)
|
var outputInformationHolder = nbstatus.ConvertToStatusOutputOverview(resp.GetFullStatus(), nbstatus.ConvertOptions{
|
||||||
|
Anonymize: anonymizeFlag,
|
||||||
|
DaemonVersion: resp.GetDaemonVersion(),
|
||||||
|
DaemonStatus: nbstatus.ParseDaemonStatus(status),
|
||||||
|
StatusFilter: statusFilter,
|
||||||
|
PrefixNamesFilter: prefixNamesFilter,
|
||||||
|
PrefixNamesFilterMap: prefixNamesFilterMap,
|
||||||
|
IPsFilter: ipsFilterMap,
|
||||||
|
ConnectionTypeFilter: connectionTypeFilter,
|
||||||
|
ProfileName: profName,
|
||||||
|
})
|
||||||
var statusOutputString string
|
var statusOutputString string
|
||||||
switch {
|
switch {
|
||||||
case detailFlag:
|
case detailFlag:
|
||||||
statusOutputString = nbstatus.ParseToFullDetailSummary(outputInformationHolder)
|
statusOutputString = outputInformationHolder.FullDetailSummary()
|
||||||
case jsonFlag:
|
case jsonFlag:
|
||||||
statusOutputString, err = nbstatus.ParseToJSON(outputInformationHolder)
|
statusOutputString, err = outputInformationHolder.JSON()
|
||||||
case yamlFlag:
|
case yamlFlag:
|
||||||
statusOutputString, err = nbstatus.ParseToYAML(outputInformationHolder)
|
statusOutputString, err = outputInformationHolder.YAML()
|
||||||
default:
|
default:
|
||||||
statusOutputString = nbstatus.ParseGeneralSummary(outputInformationHolder, false, false, false)
|
statusOutputString = outputInformationHolder.GeneralSummary(false, false, false, false)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -121,16 +149,17 @@ func statusFunc(cmd *cobra.Command, args []string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func getStatus(ctx context.Context, shouldRunProbes bool) (*proto.StatusResponse, error) {
|
func getStatus(ctx context.Context, fullPeerStatus bool, shouldRunProbes bool) (*proto.StatusResponse, error) {
|
||||||
conn, err := DialClientGRPCServer(ctx, daemonAddr)
|
conn, err := DialClientGRPCServer(ctx, daemonAddr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
//nolint
|
||||||
return nil, fmt.Errorf("failed to connect to daemon error: %v\n"+
|
return nil, fmt.Errorf("failed to connect to daemon error: %v\n"+
|
||||||
"If the daemon is not running please run: "+
|
"If the daemon is not running please run: "+
|
||||||
"\nnetbird service install \nnetbird service start\n", err)
|
"\nnetbird service install \nnetbird service start\n", err)
|
||||||
}
|
}
|
||||||
defer conn.Close()
|
defer conn.Close()
|
||||||
|
|
||||||
resp, err := proto.NewDaemonServiceClient(conn).Status(ctx, &proto.StatusRequest{GetFullPeerStatus: true, ShouldRunProbes: shouldRunProbes})
|
resp, err := proto.NewDaemonServiceClient(conn).Status(ctx, &proto.StatusRequest{GetFullPeerStatus: fullPeerStatus, ShouldRunProbes: shouldRunProbes})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("status failed: %v", status.Convert(err).Message())
|
return nil, fmt.Errorf("status failed: %v", status.Convert(err).Message())
|
||||||
}
|
}
|
||||||
@@ -184,6 +213,83 @@ func enableDetailFlagWhenFilterFlag() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func runHealthCheck(cmd *cobra.Command) error {
|
||||||
|
check := strings.ToLower(checkFlag)
|
||||||
|
switch check {
|
||||||
|
case "live", "ready", "startup":
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("unknown check %q, must be one of: live, ready, startup", checkFlag)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := util.InitLog(logLevel, util.LogConsole); err != nil {
|
||||||
|
return fmt.Errorf("init log: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := internal.CtxInitState(cmd.Context())
|
||||||
|
|
||||||
|
isStartup := check == "startup"
|
||||||
|
resp, err := getStatus(ctx, isStartup, false)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
switch check {
|
||||||
|
case "live":
|
||||||
|
return nil
|
||||||
|
case "ready":
|
||||||
|
return checkReadiness(resp)
|
||||||
|
case "startup":
|
||||||
|
return checkStartup(resp)
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkReadiness(resp *proto.StatusResponse) error {
|
||||||
|
daemonStatus := internal.StatusType(resp.GetStatus())
|
||||||
|
switch daemonStatus {
|
||||||
|
case internal.StatusIdle, internal.StatusConnecting, internal.StatusConnected:
|
||||||
|
return nil
|
||||||
|
case internal.StatusNeedsLogin, internal.StatusLoginFailed, internal.StatusSessionExpired:
|
||||||
|
return fmt.Errorf("readiness check: daemon status is %s", daemonStatus)
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("readiness check: unexpected daemon status %q", daemonStatus)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkStartup(resp *proto.StatusResponse) error {
|
||||||
|
fullStatus := resp.GetFullStatus()
|
||||||
|
if fullStatus == nil {
|
||||||
|
return fmt.Errorf("startup check: no full status available")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !fullStatus.GetManagementState().GetConnected() {
|
||||||
|
return fmt.Errorf("startup check: management not connected")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !fullStatus.GetSignalState().GetConnected() {
|
||||||
|
return fmt.Errorf("startup check: signal not connected")
|
||||||
|
}
|
||||||
|
|
||||||
|
var relayCount, relaysConnected int
|
||||||
|
for _, r := range fullStatus.GetRelays() {
|
||||||
|
uri := r.GetURI()
|
||||||
|
if !strings.HasPrefix(uri, "rel://") && !strings.HasPrefix(uri, "rels://") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
relayCount++
|
||||||
|
if r.GetAvailable() {
|
||||||
|
relaysConnected++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if relayCount > 0 && relaysConnected == 0 {
|
||||||
|
return fmt.Errorf("startup check: no relay servers available (0/%d connected)", relayCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func parseInterfaceIP(interfaceIP string) string {
|
func parseInterfaceIP(interfaceIP string) string {
|
||||||
ip, _, err := net.ParseCIDR(interfaceIP)
|
ip, _, err := net.ParseCIDR(interfaceIP)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ const (
|
|||||||
disableFirewallFlag = "disable-firewall"
|
disableFirewallFlag = "disable-firewall"
|
||||||
blockLANAccessFlag = "block-lan-access"
|
blockLANAccessFlag = "block-lan-access"
|
||||||
blockInboundFlag = "block-inbound"
|
blockInboundFlag = "block-inbound"
|
||||||
|
disableIPv6Flag = "disable-ipv6"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -17,6 +18,7 @@ var (
|
|||||||
disableFirewall bool
|
disableFirewall bool
|
||||||
blockLANAccess bool
|
blockLANAccess bool
|
||||||
blockInbound bool
|
blockInbound bool
|
||||||
|
disableIPv6 bool
|
||||||
)
|
)
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
@@ -39,4 +41,7 @@ func init() {
|
|||||||
upCmd.PersistentFlags().BoolVar(&blockInbound, blockInboundFlag, false,
|
upCmd.PersistentFlags().BoolVar(&blockInbound, blockInboundFlag, false,
|
||||||
"Block inbound connections. If enabled, the client will not allow any inbound connections to the local machine nor routed networks.\n"+
|
"Block inbound connections. If enabled, the client will not allow any inbound connections to the local machine nor routed networks.\n"+
|
||||||
"This overrides any policies received from the management service.")
|
"This overrides any policies received from the management service.")
|
||||||
|
|
||||||
|
upCmd.PersistentFlags().BoolVar(&disableIPv6, disableIPv6Flag, false,
|
||||||
|
"Disable IPv6 overlay. If enabled, the client won't request or use an IPv6 overlay address.")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,15 @@ import (
|
|||||||
|
|
||||||
"github.com/netbirdio/management-integrations/integrations"
|
"github.com/netbirdio/management-integrations/integrations"
|
||||||
|
|
||||||
|
nbcache "github.com/netbirdio/netbird/management/server/cache"
|
||||||
|
|
||||||
|
"github.com/netbirdio/netbird/management/internals/controllers/network_map/controller"
|
||||||
|
"github.com/netbirdio/netbird/management/internals/controllers/network_map/update_channel"
|
||||||
|
"github.com/netbirdio/netbird/management/internals/modules/peers"
|
||||||
|
"github.com/netbirdio/netbird/management/internals/modules/peers/ephemeral/manager"
|
||||||
|
nbgrpc "github.com/netbirdio/netbird/management/internals/shared/grpc"
|
||||||
|
"github.com/netbirdio/netbird/management/server/job"
|
||||||
|
|
||||||
clientProto "github.com/netbirdio/netbird/client/proto"
|
clientProto "github.com/netbirdio/netbird/client/proto"
|
||||||
client "github.com/netbirdio/netbird/client/server"
|
client "github.com/netbirdio/netbird/client/server"
|
||||||
"github.com/netbirdio/netbird/management/internals/server/config"
|
"github.com/netbirdio/netbird/management/internals/server/config"
|
||||||
@@ -20,8 +29,6 @@ import (
|
|||||||
"github.com/netbirdio/netbird/management/server/activity"
|
"github.com/netbirdio/netbird/management/server/activity"
|
||||||
"github.com/netbirdio/netbird/management/server/groups"
|
"github.com/netbirdio/netbird/management/server/groups"
|
||||||
"github.com/netbirdio/netbird/management/server/integrations/port_forwarding"
|
"github.com/netbirdio/netbird/management/server/integrations/port_forwarding"
|
||||||
"github.com/netbirdio/netbird/management/server/peers"
|
|
||||||
"github.com/netbirdio/netbird/management/server/peers/ephemeral/manager"
|
|
||||||
"github.com/netbirdio/netbird/management/server/permissions"
|
"github.com/netbirdio/netbird/management/server/permissions"
|
||||||
"github.com/netbirdio/netbird/management/server/settings"
|
"github.com/netbirdio/netbird/management/server/settings"
|
||||||
"github.com/netbirdio/netbird/management/server/store"
|
"github.com/netbirdio/netbird/management/server/store"
|
||||||
@@ -84,11 +91,7 @@ func startManagement(t *testing.T, config *config.Config, testFile string) (*grp
|
|||||||
}
|
}
|
||||||
t.Cleanup(cleanUp)
|
t.Cleanup(cleanUp)
|
||||||
|
|
||||||
peersUpdateManager := mgmt.NewPeersUpdateManager(nil)
|
|
||||||
eventStore := &activity.InMemoryEventStore{}
|
eventStore := &activity.InMemoryEventStore{}
|
||||||
if err != nil {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
ctrl := gomock.NewController(t)
|
ctrl := gomock.NewController(t)
|
||||||
t.Cleanup(ctrl.Finish)
|
t.Cleanup(ctrl.Finish)
|
||||||
@@ -97,9 +100,18 @@ func startManagement(t *testing.T, config *config.Config, testFile string) (*grp
|
|||||||
peersmanager := peers.NewManager(store, permissionsManagerMock)
|
peersmanager := peers.NewManager(store, permissionsManagerMock)
|
||||||
settingsManagerMock := settings.NewMockManager(ctrl)
|
settingsManagerMock := settings.NewMockManager(ctrl)
|
||||||
|
|
||||||
iv, _ := integrations.NewIntegratedValidator(context.Background(), peersmanager, settingsManagerMock, eventStore)
|
jobManager := job.NewJobManager(nil, store, peersmanager)
|
||||||
|
|
||||||
metrics, err := telemetry.NewDefaultAppMetrics(context.Background())
|
ctx := context.Background()
|
||||||
|
|
||||||
|
cacheStore, err := nbcache.NewStore(ctx, 100*time.Millisecond, 300*time.Millisecond, 100)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
iv, _ := integrations.NewIntegratedValidator(ctx, peersmanager, settingsManagerMock, eventStore, cacheStore)
|
||||||
|
|
||||||
|
metrics, err := telemetry.NewDefaultAppMetrics(ctx)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
settingsMockManager := settings.NewMockManager(ctrl)
|
settingsMockManager := settings.NewMockManager(ctrl)
|
||||||
@@ -110,13 +122,20 @@ func startManagement(t *testing.T, config *config.Config, testFile string) (*grp
|
|||||||
Return(&types.Settings{}, nil).
|
Return(&types.Settings{}, nil).
|
||||||
AnyTimes()
|
AnyTimes()
|
||||||
|
|
||||||
accountManager, err := mgmt.BuildManager(context.Background(), config, store, peersUpdateManager, nil, "", "netbird.selfhosted", eventStore, nil, false, iv, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManagerMock, false)
|
updateManager := update_channel.NewPeersUpdateManager(metrics)
|
||||||
|
requestBuffer := mgmt.NewAccountRequestBuffer(ctx, store)
|
||||||
|
networkMapController := controller.NewController(ctx, store, metrics, updateManager, requestBuffer, mgmt.MockIntegratedValidator{}, settingsMockManager, "netbird.cloud", port_forwarding.NewControllerMock(), manager.NewEphemeralManager(store, peersmanager), config)
|
||||||
|
|
||||||
|
accountManager, err := mgmt.BuildManager(ctx, config, store, networkMapController, jobManager, nil, "", eventStore, nil, false, iv, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManagerMock, false, cacheStore)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
secretsManager := mgmt.NewTimeBasedAuthSecretsManager(peersUpdateManager, config.TURNConfig, config.Relay, settingsMockManager, groupsManager)
|
secretsManager, err := nbgrpc.NewTimeBasedAuthSecretsManager(updateManager, config.TURNConfig, config.Relay, settingsMockManager, groupsManager)
|
||||||
mgmtServer, err := mgmt.NewServer(context.Background(), config, accountManager, settingsMockManager, peersUpdateManager, secretsManager, nil, &manager.EphemeralManager{}, nil, &mgmt.MockIntegratedValidator{})
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
mgmtServer, err := nbgrpc.NewServer(config, accountManager, settingsMockManager, jobManager, secretsManager, nil, nil, &mgmt.MockIntegratedValidator{}, networkMapController, nil, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -141,7 +160,7 @@ func startClientDaemon(
|
|||||||
s := grpc.NewServer()
|
s := grpc.NewServer()
|
||||||
|
|
||||||
server := client.New(ctx,
|
server := client.New(ctx,
|
||||||
"", "", false, false)
|
"", "", false, false, false, false)
|
||||||
if err := server.Start(); err != nil {
|
if err := server.Start(); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,6 +39,9 @@ const (
|
|||||||
noBrowserFlag = "no-browser"
|
noBrowserFlag = "no-browser"
|
||||||
noBrowserDesc = "do not open the browser for SSO login"
|
noBrowserDesc = "do not open the browser for SSO login"
|
||||||
|
|
||||||
|
showQRFlag = "qr"
|
||||||
|
showQRDesc = "show QR code for the SSO login URL (useful for headless machines without browser access)"
|
||||||
|
|
||||||
profileNameFlag = "profile"
|
profileNameFlag = "profile"
|
||||||
profileNameDesc = "profile name to use for the login. If not specified, the last used profile will be used."
|
profileNameDesc = "profile name to use for the login. If not specified, the last used profile will be used."
|
||||||
)
|
)
|
||||||
@@ -48,6 +51,7 @@ var (
|
|||||||
dnsLabels []string
|
dnsLabels []string
|
||||||
dnsLabelsValidated domain.List
|
dnsLabelsValidated domain.List
|
||||||
noBrowser bool
|
noBrowser bool
|
||||||
|
showQR bool
|
||||||
profileName string
|
profileName string
|
||||||
configPath string
|
configPath string
|
||||||
|
|
||||||
@@ -80,6 +84,7 @@ func init() {
|
|||||||
)
|
)
|
||||||
|
|
||||||
upCmd.PersistentFlags().BoolVar(&noBrowser, noBrowserFlag, false, noBrowserDesc)
|
upCmd.PersistentFlags().BoolVar(&noBrowser, noBrowserFlag, false, noBrowserDesc)
|
||||||
|
upCmd.PersistentFlags().BoolVar(&showQR, showQRFlag, false, showQRDesc)
|
||||||
upCmd.PersistentFlags().StringVar(&profileName, profileNameFlag, "", profileNameDesc)
|
upCmd.PersistentFlags().StringVar(&profileName, profileNameFlag, "", profileNameDesc)
|
||||||
upCmd.PersistentFlags().StringVarP(&configPath, "config", "c", "", "(DEPRECATED) NetBird config file location. ")
|
upCmd.PersistentFlags().StringVarP(&configPath, "config", "c", "", "(DEPRECATED) NetBird config file location. ")
|
||||||
|
|
||||||
@@ -185,7 +190,7 @@ func runInForegroundMode(ctx context.Context, cmd *cobra.Command, activeProf *pr
|
|||||||
|
|
||||||
_, _ = profilemanager.UpdateOldManagementURL(ctx, config, configFilePath)
|
_, _ = profilemanager.UpdateOldManagementURL(ctx, config, configFilePath)
|
||||||
|
|
||||||
err = foregroundLogin(ctx, cmd, config, providedSetupKey)
|
err = foregroundLogin(ctx, cmd, config, providedSetupKey, activeProf.Name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("foreground login failed: %v", err)
|
return fmt.Errorf("foreground login failed: %v", err)
|
||||||
}
|
}
|
||||||
@@ -200,7 +205,7 @@ func runInForegroundMode(ctx context.Context, cmd *cobra.Command, activeProf *pr
|
|||||||
connectClient := internal.NewConnectClient(ctx, config, r)
|
connectClient := internal.NewConnectClient(ctx, config, r)
|
||||||
SetupDebugHandler(ctx, config, r, connectClient, "")
|
SetupDebugHandler(ctx, config, r, connectClient, "")
|
||||||
|
|
||||||
return connectClient.Run(nil)
|
return connectClient.Run(nil, util.FindFirstLogPath(logFiles))
|
||||||
}
|
}
|
||||||
|
|
||||||
func runInDaemonMode(ctx context.Context, cmd *cobra.Command, pm *profilemanager.ProfileManager, activeProf *profilemanager.Profile, profileSwitched bool) error {
|
func runInDaemonMode(ctx context.Context, cmd *cobra.Command, pm *profilemanager.ProfileManager, activeProf *profilemanager.Profile, profileSwitched bool) error {
|
||||||
@@ -216,6 +221,7 @@ func runInDaemonMode(ctx context.Context, cmd *cobra.Command, pm *profilemanager
|
|||||||
|
|
||||||
conn, err := DialClientGRPCServer(ctx, daemonAddr)
|
conn, err := DialClientGRPCServer(ctx, daemonAddr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
//nolint
|
||||||
return fmt.Errorf("failed to connect to daemon error: %v\n"+
|
return fmt.Errorf("failed to connect to daemon error: %v\n"+
|
||||||
"If the daemon is not running please run: "+
|
"If the daemon is not running please run: "+
|
||||||
"\nnetbird service install \nnetbird service start\n", err)
|
"\nnetbird service install \nnetbird service start\n", err)
|
||||||
@@ -286,6 +292,13 @@ func doDaemonUp(ctx context.Context, cmd *cobra.Command, client proto.DaemonServ
|
|||||||
loginRequest.ProfileName = &activeProf.Name
|
loginRequest.ProfileName = &activeProf.Name
|
||||||
loginRequest.Username = &username
|
loginRequest.Username = &username
|
||||||
|
|
||||||
|
profileState, err := pm.GetProfileState(activeProf.Name)
|
||||||
|
if err != nil {
|
||||||
|
log.Debugf("failed to get profile state for login hint: %v", err)
|
||||||
|
} else if profileState.Email != "" {
|
||||||
|
loginRequest.Hint = &profileState.Email
|
||||||
|
}
|
||||||
|
|
||||||
var loginErr error
|
var loginErr error
|
||||||
var loginResp *proto.LoginResponse
|
var loginResp *proto.LoginResponse
|
||||||
|
|
||||||
@@ -355,14 +368,18 @@ func setupSetConfigReq(customDNSAddressConverted []byte, cmd *cobra.Command, pro
|
|||||||
req.EnableSSHSFTP = &enableSSHSFTP
|
req.EnableSSHSFTP = &enableSSHSFTP
|
||||||
}
|
}
|
||||||
if cmd.Flag(enableSSHLocalPortForwardFlag).Changed {
|
if cmd.Flag(enableSSHLocalPortForwardFlag).Changed {
|
||||||
req.EnableSSHLocalPortForward = &enableSSHLocalPortForward
|
req.EnableSSHLocalPortForwarding = &enableSSHLocalPortForward
|
||||||
}
|
}
|
||||||
if cmd.Flag(enableSSHRemotePortForwardFlag).Changed {
|
if cmd.Flag(enableSSHRemotePortForwardFlag).Changed {
|
||||||
req.EnableSSHRemotePortForward = &enableSSHRemotePortForward
|
req.EnableSSHRemotePortForwarding = &enableSSHRemotePortForward
|
||||||
}
|
}
|
||||||
if cmd.Flag(disableSSHAuthFlag).Changed {
|
if cmd.Flag(disableSSHAuthFlag).Changed {
|
||||||
req.DisableSSHAuth = &disableSSHAuth
|
req.DisableSSHAuth = &disableSSHAuth
|
||||||
}
|
}
|
||||||
|
if cmd.Flag(sshJWTCacheTTLFlag).Changed {
|
||||||
|
sshJWTCacheTTL32 := int32(sshJWTCacheTTL)
|
||||||
|
req.SshJWTCacheTTL = &sshJWTCacheTTL32
|
||||||
|
}
|
||||||
if cmd.Flag(interfaceNameFlag).Changed {
|
if cmd.Flag(interfaceNameFlag).Changed {
|
||||||
if err := parseInterfaceName(interfaceName); err != nil {
|
if err := parseInterfaceName(interfaceName); err != nil {
|
||||||
log.Errorf("parse interface name: %v", err)
|
log.Errorf("parse interface name: %v", err)
|
||||||
@@ -418,6 +435,10 @@ func setupSetConfigReq(customDNSAddressConverted []byte, cmd *cobra.Command, pro
|
|||||||
req.BlockInbound = &blockInbound
|
req.BlockInbound = &blockInbound
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if cmd.Flag(disableIPv6Flag).Changed {
|
||||||
|
req.DisableIpv6 = &disableIPv6
|
||||||
|
}
|
||||||
|
|
||||||
if cmd.Flag(enableLazyConnectionFlag).Changed {
|
if cmd.Flag(enableLazyConnectionFlag).Changed {
|
||||||
req.LazyConnectionEnabled = &lazyConnEnabled
|
req.LazyConnectionEnabled = &lazyConnEnabled
|
||||||
}
|
}
|
||||||
@@ -467,6 +488,10 @@ func setupConfig(customDNSAddressConverted []byte, cmd *cobra.Command, configFil
|
|||||||
ic.DisableSSHAuth = &disableSSHAuth
|
ic.DisableSSHAuth = &disableSSHAuth
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if cmd.Flag(sshJWTCacheTTLFlag).Changed {
|
||||||
|
ic.SSHJWTCacheTTL = &sshJWTCacheTTL
|
||||||
|
}
|
||||||
|
|
||||||
if cmd.Flag(interfaceNameFlag).Changed {
|
if cmd.Flag(interfaceNameFlag).Changed {
|
||||||
if err := parseInterfaceName(interfaceName); err != nil {
|
if err := parseInterfaceName(interfaceName); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -531,6 +556,10 @@ func setupConfig(customDNSAddressConverted []byte, cmd *cobra.Command, configFil
|
|||||||
ic.BlockInbound = &blockInbound
|
ic.BlockInbound = &blockInbound
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if cmd.Flag(disableIPv6Flag).Changed {
|
||||||
|
ic.DisableIPv6 = &disableIPv6
|
||||||
|
}
|
||||||
|
|
||||||
if cmd.Flag(enableLazyConnectionFlag).Changed {
|
if cmd.Flag(enableLazyConnectionFlag).Changed {
|
||||||
ic.LazyConnectionEnabled = &lazyConnEnabled
|
ic.LazyConnectionEnabled = &lazyConnEnabled
|
||||||
}
|
}
|
||||||
@@ -587,6 +616,11 @@ func setupLoginRequest(providedSetupKey string, customDNSAddressConverted []byte
|
|||||||
loginRequest.DisableSSHAuth = &disableSSHAuth
|
loginRequest.DisableSSHAuth = &disableSSHAuth
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if cmd.Flag(sshJWTCacheTTLFlag).Changed {
|
||||||
|
sshJWTCacheTTL32 := int32(sshJWTCacheTTL)
|
||||||
|
loginRequest.SshJWTCacheTTL = &sshJWTCacheTTL32
|
||||||
|
}
|
||||||
|
|
||||||
if cmd.Flag(disableAutoConnectFlag).Changed {
|
if cmd.Flag(disableAutoConnectFlag).Changed {
|
||||||
loginRequest.DisableAutoConnect = &autoConnectDisabled
|
loginRequest.DisableAutoConnect = &autoConnectDisabled
|
||||||
}
|
}
|
||||||
@@ -640,6 +674,10 @@ func setupLoginRequest(providedSetupKey string, customDNSAddressConverted []byte
|
|||||||
loginRequest.BlockInbound = &blockInbound
|
loginRequest.BlockInbound = &blockInbound
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if cmd.Flag(disableIPv6Flag).Changed {
|
||||||
|
loginRequest.DisableIpv6 = &disableIPv6
|
||||||
|
}
|
||||||
|
|
||||||
if cmd.Flag(enableLazyConnectionFlag).Changed {
|
if cmd.Flag(enableLazyConnectionFlag).Changed {
|
||||||
loginRequest.LazyConnectionEnabled = &lazyConnEnabled
|
loginRequest.LazyConnectionEnabled = &lazyConnEnabled
|
||||||
}
|
}
|
||||||
|
|||||||
13
client/cmd/update.go
Normal file
13
client/cmd/update.go
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
//go:build !windows && !darwin
|
||||||
|
|
||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
var updateCmd *cobra.Command
|
||||||
|
|
||||||
|
func isUpdateBinary() bool {
|
||||||
|
return false
|
||||||
|
}
|
||||||
75
client/cmd/update_supported.go
Normal file
75
client/cmd/update_supported.go
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
//go:build windows || darwin
|
||||||
|
|
||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
|
"github.com/netbirdio/netbird/client/internal/updater/installer"
|
||||||
|
"github.com/netbirdio/netbird/util"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
updateCmd = &cobra.Command{
|
||||||
|
Use: "update",
|
||||||
|
Short: "Update the NetBird client application",
|
||||||
|
RunE: updateFunc,
|
||||||
|
}
|
||||||
|
|
||||||
|
tempDirFlag string
|
||||||
|
installerFile string
|
||||||
|
serviceDirFlag string
|
||||||
|
dryRunFlag bool
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
updateCmd.Flags().StringVar(&tempDirFlag, "temp-dir", "", "temporary dir")
|
||||||
|
updateCmd.Flags().StringVar(&installerFile, "installer-file", "", "installer file")
|
||||||
|
updateCmd.Flags().StringVar(&serviceDirFlag, "service-dir", "", "service directory")
|
||||||
|
updateCmd.Flags().BoolVar(&dryRunFlag, "dry-run", false, "dry run the update process without making any changes")
|
||||||
|
}
|
||||||
|
|
||||||
|
// isUpdateBinary checks if the current executable is named "update" or "update.exe"
|
||||||
|
func isUpdateBinary() bool {
|
||||||
|
// Remove extension for cross-platform compatibility
|
||||||
|
execPath, err := os.Executable()
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
baseName := filepath.Base(execPath)
|
||||||
|
name := strings.TrimSuffix(baseName, filepath.Ext(baseName))
|
||||||
|
|
||||||
|
return name == installer.UpdaterBinaryNameWithoutExtension()
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateFunc(cmd *cobra.Command, args []string) error {
|
||||||
|
if err := setupLogToFile(tempDirFlag); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Infof("updater started: %s", serviceDirFlag)
|
||||||
|
updater := installer.NewWithDir(tempDirFlag)
|
||||||
|
if err := updater.Setup(context.Background(), dryRunFlag, installerFile, serviceDirFlag); err != nil {
|
||||||
|
log.Errorf("failed to update application: %v", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func setupLogToFile(dir string) error {
|
||||||
|
logFile := filepath.Join(dir, installer.LogFile)
|
||||||
|
|
||||||
|
if _, err := os.Stat(logFile); err == nil {
|
||||||
|
if err := os.Remove(logFile); err != nil {
|
||||||
|
log.Errorf("failed to remove existing log file: %v\n", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return util.InitLog(logLevel, util.LogConsole, logFile)
|
||||||
|
}
|
||||||
65
client/embed/capture.go
Normal file
65
client/embed/capture.go
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
package embed
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
|
||||||
|
"github.com/netbirdio/netbird/client/internal"
|
||||||
|
"github.com/netbirdio/netbird/util/capture"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CaptureOptions configures a packet capture session.
|
||||||
|
type CaptureOptions struct {
|
||||||
|
// Output receives pcap-formatted data. Nil disables pcap output.
|
||||||
|
Output io.Writer
|
||||||
|
// TextOutput receives human-readable packet summaries. Nil disables text output.
|
||||||
|
TextOutput io.Writer
|
||||||
|
// Filter is a BPF-like filter expression (e.g. "host 10.0.0.1 and tcp port 443").
|
||||||
|
// Empty captures all packets.
|
||||||
|
Filter string
|
||||||
|
// Verbose adds seq/ack, TTL, window, and total length to text output.
|
||||||
|
Verbose bool
|
||||||
|
// ASCII dumps transport payload as printable ASCII after each packet line.
|
||||||
|
ASCII bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// CaptureStats reports capture session counters.
|
||||||
|
type CaptureStats struct {
|
||||||
|
Packets int64
|
||||||
|
Bytes int64
|
||||||
|
Dropped int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// CaptureSession represents an active packet capture. Call Stop to end the
|
||||||
|
// capture and flush buffered packets.
|
||||||
|
type CaptureSession struct {
|
||||||
|
sess *capture.Session
|
||||||
|
engine *internal.Engine
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop ends the capture, flushes remaining packets, and detaches from the device.
|
||||||
|
// Safe to call multiple times.
|
||||||
|
func (cs *CaptureSession) Stop() {
|
||||||
|
if cs.engine != nil {
|
||||||
|
_ = cs.engine.SetCapture(nil)
|
||||||
|
cs.engine = nil
|
||||||
|
}
|
||||||
|
if cs.sess != nil {
|
||||||
|
cs.sess.Stop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stats returns current capture counters.
|
||||||
|
func (cs *CaptureSession) Stats() CaptureStats {
|
||||||
|
s := cs.sess.Stats()
|
||||||
|
return CaptureStats{
|
||||||
|
Packets: s.Packets,
|
||||||
|
Bytes: s.Bytes,
|
||||||
|
Dropped: s.Dropped,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Done returns a channel that is closed when the capture's writer goroutine
|
||||||
|
// has fully exited and all buffered packets have been flushed.
|
||||||
|
func (cs *CaptureSession) Done() <-chan struct{} {
|
||||||
|
return cs.sess.Done()
|
||||||
|
}
|
||||||
@@ -14,12 +14,17 @@ import (
|
|||||||
"github.com/sirupsen/logrus"
|
"github.com/sirupsen/logrus"
|
||||||
wgnetstack "golang.zx2c4.com/wireguard/tun/netstack"
|
wgnetstack "golang.zx2c4.com/wireguard/tun/netstack"
|
||||||
|
|
||||||
|
"github.com/netbirdio/netbird/client/iface"
|
||||||
"github.com/netbirdio/netbird/client/iface/netstack"
|
"github.com/netbirdio/netbird/client/iface/netstack"
|
||||||
"github.com/netbirdio/netbird/client/internal"
|
"github.com/netbirdio/netbird/client/internal"
|
||||||
|
"github.com/netbirdio/netbird/client/internal/auth"
|
||||||
"github.com/netbirdio/netbird/client/internal/peer"
|
"github.com/netbirdio/netbird/client/internal/peer"
|
||||||
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
||||||
sshcommon "github.com/netbirdio/netbird/client/ssh"
|
sshcommon "github.com/netbirdio/netbird/client/ssh"
|
||||||
"github.com/netbirdio/netbird/client/system"
|
"github.com/netbirdio/netbird/client/system"
|
||||||
|
"github.com/netbirdio/netbird/shared/management/domain"
|
||||||
|
mgmProto "github.com/netbirdio/netbird/shared/management/proto"
|
||||||
|
"github.com/netbirdio/netbird/util/capture"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -29,6 +34,14 @@ var (
|
|||||||
ErrConfigNotInitialized = errors.New("config not initialized")
|
ErrConfigNotInitialized = errors.New("config not initialized")
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// PeerStatusConnected indicates the peer is in connected state.
|
||||||
|
PeerStatusConnected = peer.StatusConnected
|
||||||
|
)
|
||||||
|
|
||||||
|
// PeerConnStatus is a peer's connection status.
|
||||||
|
type PeerConnStatus = peer.ConnStatus
|
||||||
|
|
||||||
// Client manages a netbird embedded client instance.
|
// Client manages a netbird embedded client instance.
|
||||||
type Client struct {
|
type Client struct {
|
||||||
deviceName string
|
deviceName string
|
||||||
@@ -38,6 +51,7 @@ type Client struct {
|
|||||||
setupKey string
|
setupKey string
|
||||||
jwtToken string
|
jwtToken string
|
||||||
connect *internal.ConnectClient
|
connect *internal.ConnectClient
|
||||||
|
recorder *peer.Status
|
||||||
}
|
}
|
||||||
|
|
||||||
// Options configures a new Client.
|
// Options configures a new Client.
|
||||||
@@ -52,7 +66,7 @@ type Options struct {
|
|||||||
PrivateKey string
|
PrivateKey string
|
||||||
// ManagementURL overrides the default management server URL
|
// ManagementURL overrides the default management server URL
|
||||||
ManagementURL string
|
ManagementURL string
|
||||||
// PreSharedKey is the pre-shared key for the WireGuard interface
|
// PreSharedKey is the pre-shared key for the tunnel interface
|
||||||
PreSharedKey string
|
PreSharedKey string
|
||||||
// LogOutput is the output destination for logs (defaults to os.Stderr if nil)
|
// LogOutput is the output destination for logs (defaults to os.Stderr if nil)
|
||||||
LogOutput io.Writer
|
LogOutput io.Writer
|
||||||
@@ -66,6 +80,20 @@ type Options struct {
|
|||||||
StatePath string
|
StatePath string
|
||||||
// DisableClientRoutes disables the client routes
|
// DisableClientRoutes disables the client routes
|
||||||
DisableClientRoutes bool
|
DisableClientRoutes bool
|
||||||
|
// DisableIPv6 disables IPv6 overlay addressing
|
||||||
|
DisableIPv6 bool
|
||||||
|
// BlockInbound blocks all inbound connections from peers
|
||||||
|
BlockInbound bool
|
||||||
|
// WireguardPort is the port for the tunnel interface. Use 0 for a random port.
|
||||||
|
WireguardPort *int
|
||||||
|
// MTU is the MTU for the tunnel interface.
|
||||||
|
// Valid values are in the range 576..8192 bytes.
|
||||||
|
// If non-nil, this value overrides any value stored in the config file.
|
||||||
|
// If nil, the existing config MTU (if non-zero) is preserved; otherwise it defaults to 1280.
|
||||||
|
// Set to a higher value (e.g. 1400) if carrying QUIC or other protocols that require larger datagrams.
|
||||||
|
MTU *uint16
|
||||||
|
// DNSLabels defines additional DNS labels configured in the peer.
|
||||||
|
DNSLabels []string
|
||||||
}
|
}
|
||||||
|
|
||||||
// validateCredentials checks that exactly one credential type is provided
|
// validateCredentials checks that exactly one credential type is provided
|
||||||
@@ -97,6 +125,12 @@ func New(opts Options) (*Client, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if opts.MTU != nil {
|
||||||
|
if err := iface.ValidateMTU(*opts.MTU); err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid MTU: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if opts.LogOutput != nil {
|
if opts.LogOutput != nil {
|
||||||
logrus.SetOutput(opts.LogOutput)
|
logrus.SetOutput(opts.LogOutput)
|
||||||
}
|
}
|
||||||
@@ -125,15 +159,25 @@ func New(opts Options) (*Client, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var err error
|
||||||
|
var parsedLabels domain.List
|
||||||
|
if parsedLabels, err = domain.FromStringList(opts.DNSLabels); err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid dns labels: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
t := true
|
t := true
|
||||||
var config *profilemanager.Config
|
var config *profilemanager.Config
|
||||||
var err error
|
|
||||||
input := profilemanager.ConfigInput{
|
input := profilemanager.ConfigInput{
|
||||||
ConfigPath: opts.ConfigPath,
|
ConfigPath: opts.ConfigPath,
|
||||||
ManagementURL: opts.ManagementURL,
|
ManagementURL: opts.ManagementURL,
|
||||||
PreSharedKey: &opts.PreSharedKey,
|
PreSharedKey: &opts.PreSharedKey,
|
||||||
DisableServerRoutes: &t,
|
DisableServerRoutes: &t,
|
||||||
DisableClientRoutes: &opts.DisableClientRoutes,
|
DisableClientRoutes: &opts.DisableClientRoutes,
|
||||||
|
DisableIPv6: &opts.DisableIPv6,
|
||||||
|
BlockInbound: &opts.BlockInbound,
|
||||||
|
WireguardPort: opts.WireguardPort,
|
||||||
|
MTU: opts.MTU,
|
||||||
|
DNSLabels: parsedLabels,
|
||||||
}
|
}
|
||||||
if opts.ConfigPath != "" {
|
if opts.ConfigPath != "" {
|
||||||
config, err = profilemanager.UpdateOrCreateConfig(input)
|
config, err = profilemanager.UpdateOrCreateConfig(input)
|
||||||
@@ -153,6 +197,7 @@ func New(opts Options) (*Client, error) {
|
|||||||
setupKey: opts.SetupKey,
|
setupKey: opts.SetupKey,
|
||||||
jwtToken: opts.JWTToken,
|
jwtToken: opts.JWTToken,
|
||||||
config: config,
|
config: config,
|
||||||
|
recorder: peer.NewRecorder(config.ManagementURL.String()),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -161,26 +206,38 @@ func New(opts Options) (*Client, error) {
|
|||||||
func (c *Client) Start(startCtx context.Context) error {
|
func (c *Client) Start(startCtx context.Context) error {
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
defer c.mu.Unlock()
|
defer c.mu.Unlock()
|
||||||
if c.cancel != nil {
|
if c.connect != nil {
|
||||||
return ErrClientAlreadyStarted
|
return ErrClientAlreadyStarted
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx := internal.CtxInitState(context.Background())
|
ctx, cancel := context.WithCancel(internal.CtxInitState(context.Background()))
|
||||||
|
defer func() {
|
||||||
|
if c.connect == nil {
|
||||||
|
cancel()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
// nolint:staticcheck
|
// nolint:staticcheck
|
||||||
ctx = context.WithValue(ctx, system.DeviceNameCtxKey, c.deviceName)
|
ctx = context.WithValue(ctx, system.DeviceNameCtxKey, c.deviceName)
|
||||||
if err := internal.Login(ctx, c.config, c.setupKey, c.jwtToken); err != nil {
|
|
||||||
|
authClient, err := auth.NewAuth(ctx, c.config.PrivateKey, c.config.ManagementURL, c.config)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("create auth client: %w", err)
|
||||||
|
}
|
||||||
|
defer authClient.Close()
|
||||||
|
|
||||||
|
if err, _ := authClient.Login(ctx, c.setupKey, c.jwtToken); err != nil {
|
||||||
return fmt.Errorf("login: %w", err)
|
return fmt.Errorf("login: %w", err)
|
||||||
}
|
}
|
||||||
|
client := internal.NewConnectClient(ctx, c.config, c.recorder)
|
||||||
recorder := peer.NewRecorder(c.config.ManagementURL.String())
|
client.SetSyncResponsePersistence(true)
|
||||||
client := internal.NewConnectClient(ctx, c.config, recorder)
|
|
||||||
|
|
||||||
// either startup error (permanent backoff err) or nil err (successful engine up)
|
// either startup error (permanent backoff err) or nil err (successful engine up)
|
||||||
// TODO: make after-startup backoff err available
|
// TODO: make after-startup backoff err available
|
||||||
run := make(chan struct{})
|
run := make(chan struct{})
|
||||||
clientErr := make(chan error, 1)
|
clientErr := make(chan error, 1)
|
||||||
go func() {
|
go func() {
|
||||||
if err := client.Run(run); err != nil {
|
if err := client.Run(run, ""); err != nil {
|
||||||
clientErr <- err
|
clientErr <- err
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
@@ -197,6 +254,7 @@ func (c *Client) Start(startCtx context.Context) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
c.connect = client
|
c.connect = client
|
||||||
|
c.cancel = cancel
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -211,17 +269,23 @@ func (c *Client) Stop(ctx context.Context) error {
|
|||||||
return ErrClientNotStarted
|
return ErrClientNotStarted
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if c.cancel != nil {
|
||||||
|
c.cancel()
|
||||||
|
c.cancel = nil
|
||||||
|
}
|
||||||
|
|
||||||
done := make(chan error, 1)
|
done := make(chan error, 1)
|
||||||
|
connect := c.connect
|
||||||
go func() {
|
go func() {
|
||||||
done <- c.connect.Stop()
|
done <- connect.Stop()
|
||||||
}()
|
}()
|
||||||
|
|
||||||
select {
|
select {
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
c.cancel = nil
|
c.connect = nil
|
||||||
return ctx.Err()
|
return ctx.Err()
|
||||||
case err := <-done:
|
case err := <-done:
|
||||||
c.cancel = nil
|
c.connect = nil
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("stop: %w", err)
|
return fmt.Errorf("stop: %w", err)
|
||||||
}
|
}
|
||||||
@@ -315,6 +379,83 @@ func (c *Client) NewHTTPClient() *http.Client {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Expose exposes a local service via the NetBird reverse proxy, making it accessible through a public URL.
|
||||||
|
// It returns an ExposeSession. Call Wait on the session to keep it alive.
|
||||||
|
func (c *Client) Expose(ctx context.Context, req ExposeRequest) (*ExposeSession, error) {
|
||||||
|
engine, err := c.getEngine()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
mgr := engine.GetExposeManager()
|
||||||
|
if mgr == nil {
|
||||||
|
return nil, fmt.Errorf("expose manager not available")
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := mgr.Expose(ctx, req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("expose: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &ExposeSession{
|
||||||
|
Domain: resp.Domain,
|
||||||
|
ServiceName: resp.ServiceName,
|
||||||
|
ServiceURL: resp.ServiceURL,
|
||||||
|
mgr: mgr,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Status returns the current status of the client.
|
||||||
|
func (c *Client) Status() (peer.FullStatus, error) {
|
||||||
|
c.mu.Lock()
|
||||||
|
connect := c.connect
|
||||||
|
c.mu.Unlock()
|
||||||
|
|
||||||
|
if connect != nil {
|
||||||
|
engine := connect.Engine()
|
||||||
|
if engine != nil {
|
||||||
|
_ = engine.RunHealthProbes(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.recorder.GetFullStatus(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetLatestSyncResponse returns the latest sync response from the management server.
|
||||||
|
func (c *Client) GetLatestSyncResponse() (*mgmProto.SyncResponse, error) {
|
||||||
|
engine, err := c.getEngine()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
syncResp, err := engine.GetLatestSyncResponse()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("get sync response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return syncResp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetLogLevel sets the logging level for the client and its components.
|
||||||
|
func (c *Client) SetLogLevel(levelStr string) error {
|
||||||
|
level, err := logrus.ParseLevel(levelStr)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("parse log level: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
logrus.SetLevel(level)
|
||||||
|
|
||||||
|
c.mu.Lock()
|
||||||
|
connect := c.connect
|
||||||
|
c.mu.Unlock()
|
||||||
|
|
||||||
|
if connect != nil {
|
||||||
|
connect.SetLogLevel(level)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// VerifySSHHostKey verifies an SSH host key against stored peer keys.
|
// VerifySSHHostKey verifies an SSH host key against stored peer keys.
|
||||||
// Returns nil if the key matches, ErrPeerNotFound if peer is not in network,
|
// Returns nil if the key matches, ErrPeerNotFound if peer is not in network,
|
||||||
// ErrNoStoredKey if peer has no stored key, or an error for verification failures.
|
// ErrNoStoredKey if peer has no stored key, or an error for verification failures.
|
||||||
@@ -332,6 +473,52 @@ func (c *Client) VerifySSHHostKey(peerAddress string, key []byte) error {
|
|||||||
return sshcommon.VerifyHostKey(storedKey, key, peerAddress)
|
return sshcommon.VerifyHostKey(storedKey, key, peerAddress)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// StartCapture begins capturing packets on this client's tunnel device.
|
||||||
|
// Only one capture can be active at a time; starting a new one stops the previous.
|
||||||
|
// Call StopCapture (or CaptureSession.Stop) to end it.
|
||||||
|
func (c *Client) StartCapture(opts CaptureOptions) (*CaptureSession, error) {
|
||||||
|
engine, err := c.getEngine()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var matcher capture.Matcher
|
||||||
|
if opts.Filter != "" {
|
||||||
|
m, err := capture.ParseFilter(opts.Filter)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("parse filter: %w", err)
|
||||||
|
}
|
||||||
|
matcher = m
|
||||||
|
}
|
||||||
|
|
||||||
|
sess, err := capture.NewSession(capture.Options{
|
||||||
|
Output: opts.Output,
|
||||||
|
TextOutput: opts.TextOutput,
|
||||||
|
Matcher: matcher,
|
||||||
|
Verbose: opts.Verbose,
|
||||||
|
ASCII: opts.ASCII,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("create capture session: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := engine.SetCapture(sess); err != nil {
|
||||||
|
sess.Stop()
|
||||||
|
return nil, fmt.Errorf("set capture: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &CaptureSession{sess: sess, engine: engine}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// StopCapture stops the active capture session if one is running.
|
||||||
|
func (c *Client) StopCapture() error {
|
||||||
|
engine, err := c.getEngine()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return engine.SetCapture(nil)
|
||||||
|
}
|
||||||
|
|
||||||
// getEngine safely retrieves the engine from the client with proper locking.
|
// getEngine safely retrieves the engine from the client with proper locking.
|
||||||
// Returns ErrClientNotStarted if the client is not started.
|
// Returns ErrClientNotStarted if the client is not started.
|
||||||
// Returns ErrEngineNotStarted if the engine is not available.
|
// Returns ErrEngineNotStarted if the engine is not available.
|
||||||
|
|||||||
45
client/embed/expose.go
Normal file
45
client/embed/expose.go
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
package embed
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
|
||||||
|
"github.com/netbirdio/netbird/client/internal/expose"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// ExposeProtocolHTTP exposes the service as HTTP.
|
||||||
|
ExposeProtocolHTTP = expose.ProtocolHTTP
|
||||||
|
// ExposeProtocolHTTPS exposes the service as HTTPS.
|
||||||
|
ExposeProtocolHTTPS = expose.ProtocolHTTPS
|
||||||
|
// ExposeProtocolTCP exposes the service as TCP.
|
||||||
|
ExposeProtocolTCP = expose.ProtocolTCP
|
||||||
|
// ExposeProtocolUDP exposes the service as UDP.
|
||||||
|
ExposeProtocolUDP = expose.ProtocolUDP
|
||||||
|
// ExposeProtocolTLS exposes the service as TLS.
|
||||||
|
ExposeProtocolTLS = expose.ProtocolTLS
|
||||||
|
)
|
||||||
|
|
||||||
|
// ExposeRequest is a request to expose a local service via the NetBird reverse proxy.
|
||||||
|
type ExposeRequest = expose.Request
|
||||||
|
|
||||||
|
// ExposeProtocolType represents the protocol used for exposing a service.
|
||||||
|
type ExposeProtocolType = expose.ProtocolType
|
||||||
|
|
||||||
|
// ExposeSession represents an active expose session. Use Wait to block until the session ends.
|
||||||
|
type ExposeSession struct {
|
||||||
|
Domain string
|
||||||
|
ServiceName string
|
||||||
|
ServiceURL string
|
||||||
|
|
||||||
|
mgr *expose.Manager
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait blocks while keeping the expose session alive.
|
||||||
|
// It returns when ctx is cancelled or a keep-alive error occurs, then terminates the session.
|
||||||
|
func (s *ExposeSession) Wait(ctx context.Context) error {
|
||||||
|
if s == nil || s.mgr == nil {
|
||||||
|
return errors.New("expose session is not initialized")
|
||||||
|
}
|
||||||
|
return s.mgr.KeepAlive(ctx, s.Domain)
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
"github.com/coreos/go-iptables/iptables"
|
"github.com/coreos/go-iptables/iptables"
|
||||||
"github.com/google/nftables"
|
"github.com/google/nftables"
|
||||||
@@ -35,20 +36,34 @@ const SKIP_NFTABLES_ENV = "NB_SKIP_NFTABLES_CHECK"
|
|||||||
type FWType int
|
type FWType int
|
||||||
|
|
||||||
func NewFirewall(iface IFaceMapper, stateManager *statemanager.Manager, flowLogger nftypes.FlowLogger, disableServerRoutes bool, mtu uint16) (firewall.Manager, error) {
|
func NewFirewall(iface IFaceMapper, stateManager *statemanager.Manager, flowLogger nftypes.FlowLogger, disableServerRoutes bool, mtu uint16) (firewall.Manager, error) {
|
||||||
// on the linux system we try to user nftables or iptables
|
// We run in userspace mode and force userspace firewall was requested. We don't attempt native firewall.
|
||||||
// in any case, because we need to allow netbird interface traffic
|
if iface.IsUserspaceBind() && forceUserspaceFirewall() {
|
||||||
// so we use AllowNetbird traffic from these firewall managers
|
log.Info("forcing userspace firewall")
|
||||||
// for the userspace packet filtering firewall
|
return createUserspaceFirewall(iface, nil, disableServerRoutes, flowLogger, mtu)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use native firewall for either kernel or userspace, the interface appears identical to netfilter
|
||||||
fm, err := createNativeFirewall(iface, stateManager, disableServerRoutes, mtu)
|
fm, err := createNativeFirewall(iface, stateManager, disableServerRoutes, mtu)
|
||||||
|
|
||||||
|
// Kernel cannot fall back to anything else, need to return error
|
||||||
if !iface.IsUserspaceBind() {
|
if !iface.IsUserspaceBind() {
|
||||||
return fm, err
|
return fm, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fall back to the userspace packet filter if native is unavailable
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Warnf("failed to create native firewall: %v. Proceeding with userspace", err)
|
log.Warnf("failed to create native firewall: %v. Proceeding with userspace", err)
|
||||||
|
return createUserspaceFirewall(iface, nil, disableServerRoutes, flowLogger, mtu)
|
||||||
}
|
}
|
||||||
return createUserspaceFirewall(iface, fm, disableServerRoutes, flowLogger, mtu)
|
|
||||||
|
// Native firewall handles packet filtering, but the userspace WireGuard bind
|
||||||
|
// needs a device filter for DNS interception hooks. Install a minimal
|
||||||
|
// hooks-only filter that passes all traffic through to the kernel firewall.
|
||||||
|
if err := iface.SetFilter(&uspfilter.HooksFilter{}); err != nil {
|
||||||
|
log.Warnf("failed to set hooks filter, DNS via memory hooks will not work: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return fm, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func createNativeFirewall(iface IFaceMapper, stateManager *statemanager.Manager, routes bool, mtu uint16) (firewall.Manager, error) {
|
func createNativeFirewall(iface IFaceMapper, stateManager *statemanager.Manager, routes bool, mtu uint16) (firewall.Manager, error) {
|
||||||
@@ -160,3 +175,17 @@ func isIptablesClientAvailable(client *iptables.IPTables) bool {
|
|||||||
_, err := client.ListChains("filter")
|
_, err := client.ListChains("filter")
|
||||||
return err == nil
|
return err == nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func forceUserspaceFirewall() bool {
|
||||||
|
val := os.Getenv(EnvForceUserspaceFirewall)
|
||||||
|
if val == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
force, err := strconv.ParseBool(val)
|
||||||
|
if err != nil {
|
||||||
|
log.Warnf("failed to parse %s: %v", EnvForceUserspaceFirewall, err)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return force
|
||||||
|
}
|
||||||
|
|||||||
11
client/firewall/firewalld/firewalld.go
Normal file
11
client/firewall/firewalld/firewalld.go
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
// Package firewalld integrates with the firewalld daemon so NetBird can place
|
||||||
|
// its wg interface into firewalld's "trusted" zone. This is required because
|
||||||
|
// firewalld's nftables chains are created with NFT_CHAIN_OWNER on recent
|
||||||
|
// versions, which returns EPERM to any other process that tries to insert
|
||||||
|
// rules into them. The workaround mirrors what Tailscale does: let firewalld
|
||||||
|
// itself add the accept rules to its own chains by trusting the interface.
|
||||||
|
package firewalld
|
||||||
|
|
||||||
|
// TrustedZone is the firewalld zone name used for interfaces whose traffic
|
||||||
|
// should bypass firewalld filtering.
|
||||||
|
const TrustedZone = "trusted"
|
||||||
260
client/firewall/firewalld/firewalld_linux.go
Normal file
260
client/firewall/firewalld/firewalld_linux.go
Normal file
@@ -0,0 +1,260 @@
|
|||||||
|
//go:build linux
|
||||||
|
|
||||||
|
package firewalld
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os/exec"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/godbus/dbus/v5"
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
dbusDest = "org.fedoraproject.FirewallD1"
|
||||||
|
dbusPath = "/org/fedoraproject/FirewallD1"
|
||||||
|
dbusRootIface = "org.fedoraproject.FirewallD1"
|
||||||
|
dbusZoneIface = "org.fedoraproject.FirewallD1.zone"
|
||||||
|
|
||||||
|
errZoneAlreadySet = "ZONE_ALREADY_SET"
|
||||||
|
errAlreadyEnabled = "ALREADY_ENABLED"
|
||||||
|
errUnknownIface = "UNKNOWN_INTERFACE"
|
||||||
|
errNotEnabled = "NOT_ENABLED"
|
||||||
|
|
||||||
|
// callTimeout bounds each individual DBus or firewall-cmd invocation.
|
||||||
|
// A fresh context is created for each call so a slow DBus probe can't
|
||||||
|
// exhaust the deadline before the firewall-cmd fallback gets to run.
|
||||||
|
callTimeout = 3 * time.Second
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
errDBusUnavailable = errors.New("firewalld dbus unavailable")
|
||||||
|
|
||||||
|
// trustLogOnce ensures the "added to trusted zone" message is logged at
|
||||||
|
// Info level only for the first successful add per process; repeat adds
|
||||||
|
// from other init paths are quieter.
|
||||||
|
trustLogOnce sync.Once
|
||||||
|
|
||||||
|
parentCtxMu sync.RWMutex
|
||||||
|
parentCtx context.Context = context.Background()
|
||||||
|
)
|
||||||
|
|
||||||
|
// SetParentContext installs a parent context whose cancellation aborts any
|
||||||
|
// in-flight TrustInterface call. It does not affect UntrustInterface, which
|
||||||
|
// always uses a fresh Background-rooted timeout so cleanup can still run
|
||||||
|
// during engine shutdown when the engine context is already cancelled.
|
||||||
|
func SetParentContext(ctx context.Context) {
|
||||||
|
parentCtxMu.Lock()
|
||||||
|
parentCtx = ctx
|
||||||
|
parentCtxMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func getParentContext() context.Context {
|
||||||
|
parentCtxMu.RLock()
|
||||||
|
defer parentCtxMu.RUnlock()
|
||||||
|
return parentCtx
|
||||||
|
}
|
||||||
|
|
||||||
|
// TrustInterface places iface into firewalld's trusted zone if firewalld is
|
||||||
|
// running. It is idempotent and best-effort: errors are returned so callers
|
||||||
|
// can log, but a non-running firewalld is not an error. Only the first
|
||||||
|
// successful call per process logs at Info. Respects the parent context set
|
||||||
|
// via SetParentContext so startup-time cancellation unblocks it.
|
||||||
|
func TrustInterface(iface string) error {
|
||||||
|
parent := getParentContext()
|
||||||
|
if !isRunning(parent) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := addTrusted(parent, iface); err != nil {
|
||||||
|
return fmt.Errorf("add %s to firewalld trusted zone: %w", iface, err)
|
||||||
|
}
|
||||||
|
trustLogOnce.Do(func() {
|
||||||
|
log.Infof("added %s to firewalld trusted zone", iface)
|
||||||
|
})
|
||||||
|
log.Debugf("firewalld: ensured %s is in trusted zone", iface)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UntrustInterface removes iface from firewalld's trusted zone if firewalld
|
||||||
|
// is running. Idempotent. Uses a Background-rooted timeout so it still runs
|
||||||
|
// during shutdown after the engine context has been cancelled.
|
||||||
|
func UntrustInterface(iface string) error {
|
||||||
|
if !isRunning(context.Background()) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := removeTrusted(context.Background(), iface); err != nil {
|
||||||
|
return fmt.Errorf("remove %s from firewalld trusted zone: %w", iface, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func newCallContext(parent context.Context) (context.Context, context.CancelFunc) {
|
||||||
|
return context.WithTimeout(parent, callTimeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
func isRunning(parent context.Context) bool {
|
||||||
|
ctx, cancel := newCallContext(parent)
|
||||||
|
ok, err := isRunningDBus(ctx)
|
||||||
|
cancel()
|
||||||
|
if err == nil {
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
if errors.Is(err, errDBusUnavailable) || errors.Is(err, context.DeadlineExceeded) {
|
||||||
|
ctx, cancel = newCallContext(parent)
|
||||||
|
defer cancel()
|
||||||
|
return isRunningCLI(ctx)
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func addTrusted(parent context.Context, iface string) error {
|
||||||
|
ctx, cancel := newCallContext(parent)
|
||||||
|
err := addDBus(ctx, iface)
|
||||||
|
cancel()
|
||||||
|
if err == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if !errors.Is(err, errDBusUnavailable) {
|
||||||
|
log.Debugf("firewalld: dbus add failed, falling back to firewall-cmd: %v", err)
|
||||||
|
}
|
||||||
|
ctx, cancel = newCallContext(parent)
|
||||||
|
defer cancel()
|
||||||
|
return addCLI(ctx, iface)
|
||||||
|
}
|
||||||
|
|
||||||
|
func removeTrusted(parent context.Context, iface string) error {
|
||||||
|
ctx, cancel := newCallContext(parent)
|
||||||
|
err := removeDBus(ctx, iface)
|
||||||
|
cancel()
|
||||||
|
if err == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if !errors.Is(err, errDBusUnavailable) {
|
||||||
|
log.Debugf("firewalld: dbus remove failed, falling back to firewall-cmd: %v", err)
|
||||||
|
}
|
||||||
|
ctx, cancel = newCallContext(parent)
|
||||||
|
defer cancel()
|
||||||
|
return removeCLI(ctx, iface)
|
||||||
|
}
|
||||||
|
|
||||||
|
func isRunningDBus(ctx context.Context) (bool, error) {
|
||||||
|
conn, err := dbus.SystemBus()
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("%w: %v", errDBusUnavailable, err)
|
||||||
|
}
|
||||||
|
obj := conn.Object(dbusDest, dbusPath)
|
||||||
|
|
||||||
|
var zone string
|
||||||
|
if err := obj.CallWithContext(ctx, dbusRootIface+".getDefaultZone", 0).Store(&zone); err != nil {
|
||||||
|
return false, fmt.Errorf("firewalld getDefaultZone: %w", err)
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func isRunningCLI(ctx context.Context) bool {
|
||||||
|
if _, err := exec.LookPath("firewall-cmd"); err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return exec.CommandContext(ctx, "firewall-cmd", "--state").Run() == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func addDBus(ctx context.Context, iface string) error {
|
||||||
|
conn, err := dbus.SystemBus()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("%w: %v", errDBusUnavailable, err)
|
||||||
|
}
|
||||||
|
obj := conn.Object(dbusDest, dbusPath)
|
||||||
|
|
||||||
|
call := obj.CallWithContext(ctx, dbusZoneIface+".addInterface", 0, TrustedZone, iface)
|
||||||
|
if call.Err == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if dbusErrContains(call.Err, errAlreadyEnabled) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if dbusErrContains(call.Err, errZoneAlreadySet) {
|
||||||
|
move := obj.CallWithContext(ctx, dbusZoneIface+".changeZoneOfInterface", 0, TrustedZone, iface)
|
||||||
|
if move.Err != nil {
|
||||||
|
return fmt.Errorf("firewalld changeZoneOfInterface: %w", move.Err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Errorf("firewalld addInterface: %w", call.Err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func removeDBus(ctx context.Context, iface string) error {
|
||||||
|
conn, err := dbus.SystemBus()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("%w: %v", errDBusUnavailable, err)
|
||||||
|
}
|
||||||
|
obj := conn.Object(dbusDest, dbusPath)
|
||||||
|
|
||||||
|
call := obj.CallWithContext(ctx, dbusZoneIface+".removeInterface", 0, TrustedZone, iface)
|
||||||
|
if call.Err == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if dbusErrContains(call.Err, errUnknownIface) || dbusErrContains(call.Err, errNotEnabled) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Errorf("firewalld removeInterface: %w", call.Err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func addCLI(ctx context.Context, iface string) error {
|
||||||
|
if _, err := exec.LookPath("firewall-cmd"); err != nil {
|
||||||
|
return fmt.Errorf("firewall-cmd not available: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --change-interface (no --permanent) binds the interface for the
|
||||||
|
// current runtime only; we do not want membership to persist across
|
||||||
|
// reboots because netbird re-asserts it on every startup.
|
||||||
|
out, err := exec.CommandContext(ctx,
|
||||||
|
"firewall-cmd", "--zone="+TrustedZone, "--change-interface="+iface,
|
||||||
|
).CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("firewall-cmd change-interface: %w: %s", err, strings.TrimSpace(string(out)))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func removeCLI(ctx context.Context, iface string) error {
|
||||||
|
if _, err := exec.LookPath("firewall-cmd"); err != nil {
|
||||||
|
return fmt.Errorf("firewall-cmd not available: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
out, err := exec.CommandContext(ctx,
|
||||||
|
"firewall-cmd", "--zone="+TrustedZone, "--remove-interface="+iface,
|
||||||
|
).CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
msg := strings.TrimSpace(string(out))
|
||||||
|
if strings.Contains(msg, errUnknownIface) || strings.Contains(msg, errNotEnabled) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return fmt.Errorf("firewall-cmd remove-interface: %w: %s", err, msg)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func dbusErrContains(err error, code string) bool {
|
||||||
|
if err == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
var de dbus.Error
|
||||||
|
if errors.As(err, &de) {
|
||||||
|
for _, b := range de.Body {
|
||||||
|
if s, ok := b.(string); ok && strings.Contains(s, code) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.Contains(err.Error(), code)
|
||||||
|
}
|
||||||
49
client/firewall/firewalld/firewalld_linux_test.go
Normal file
49
client/firewall/firewalld/firewalld_linux_test.go
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
//go:build linux
|
||||||
|
|
||||||
|
package firewalld
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/godbus/dbus/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDBusErrContains(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
err error
|
||||||
|
code string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{"nil error", nil, errZoneAlreadySet, false},
|
||||||
|
{"plain error match", errors.New("ZONE_ALREADY_SET: wt0"), errZoneAlreadySet, true},
|
||||||
|
{"plain error miss", errors.New("something else"), errZoneAlreadySet, false},
|
||||||
|
{
|
||||||
|
"dbus.Error body match",
|
||||||
|
dbus.Error{Name: "org.fedoraproject.FirewallD1.Exception", Body: []any{"ZONE_ALREADY_SET: wt0"}},
|
||||||
|
errZoneAlreadySet,
|
||||||
|
true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"dbus.Error body miss",
|
||||||
|
dbus.Error{Name: "org.fedoraproject.FirewallD1.Exception", Body: []any{"INVALID_INTERFACE"}},
|
||||||
|
errAlreadyEnabled,
|
||||||
|
false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"dbus.Error non-string body falls back to Error()",
|
||||||
|
dbus.Error{Name: "x", Body: []any{123}},
|
||||||
|
"x",
|
||||||
|
true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
got := dbusErrContains(tc.err, tc.code)
|
||||||
|
if got != tc.want {
|
||||||
|
t.Fatalf("dbusErrContains(%v, %q) = %v; want %v", tc.err, tc.code, got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
25
client/firewall/firewalld/firewalld_other.go
Normal file
25
client/firewall/firewalld/firewalld_other.go
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
//go:build !linux
|
||||||
|
|
||||||
|
package firewalld
|
||||||
|
|
||||||
|
import "context"
|
||||||
|
|
||||||
|
// SetParentContext is a no-op on non-Linux platforms because firewalld only
|
||||||
|
// runs on Linux.
|
||||||
|
func SetParentContext(context.Context) {
|
||||||
|
// intentionally empty: firewalld is a Linux-only daemon
|
||||||
|
}
|
||||||
|
|
||||||
|
// TrustInterface is a no-op on non-Linux platforms because firewalld only
|
||||||
|
// runs on Linux.
|
||||||
|
func TrustInterface(string) error {
|
||||||
|
// intentionally empty: firewalld is a Linux-only daemon
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UntrustInterface is a no-op on non-Linux platforms because firewalld only
|
||||||
|
// runs on Linux.
|
||||||
|
func UntrustInterface(string) error {
|
||||||
|
// intentionally empty: firewalld is a Linux-only daemon
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -7,6 +7,12 @@ import (
|
|||||||
"github.com/netbirdio/netbird/client/iface/wgaddr"
|
"github.com/netbirdio/netbird/client/iface/wgaddr"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// EnvForceUserspaceFirewall forces the use of the userspace packet filter even when
|
||||||
|
// native iptables/nftables is available. This only applies when the WireGuard interface
|
||||||
|
// runs in userspace mode. When set, peer ACLs are handled by USPFilter instead of
|
||||||
|
// kernel netfilter rules.
|
||||||
|
const EnvForceUserspaceFirewall = "NB_FORCE_USERSPACE_FIREWALL"
|
||||||
|
|
||||||
// IFaceMapper defines subset methods of interface required for manager
|
// IFaceMapper defines subset methods of interface required for manager
|
||||||
type IFaceMapper interface {
|
type IFaceMapper interface {
|
||||||
Name() string
|
Name() string
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
package iptables
|
package iptables
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
"slices"
|
"slices"
|
||||||
|
|
||||||
"github.com/coreos/go-iptables/iptables"
|
"github.com/coreos/go-iptables/iptables"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"github.com/nadoo/ipset"
|
ipset "github.com/lrh3321/ipset-go"
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
|
|
||||||
firewall "github.com/netbirdio/netbird/client/firewall/manager"
|
firewall "github.com/netbirdio/netbird/client/firewall/manager"
|
||||||
@@ -20,6 +21,10 @@ const (
|
|||||||
|
|
||||||
// rules chains contains the effective ACL rules
|
// rules chains contains the effective ACL rules
|
||||||
chainNameInputRules = "NETBIRD-ACL-INPUT"
|
chainNameInputRules = "NETBIRD-ACL-INPUT"
|
||||||
|
|
||||||
|
// mangleFwdKey is the entries map key for mangle FORWARD guard rules that prevent
|
||||||
|
// external DNAT from bypassing ACL rules.
|
||||||
|
mangleFwdKey = "MANGLE-FORWARD"
|
||||||
)
|
)
|
||||||
|
|
||||||
type aclEntries map[string][][]string
|
type aclEntries map[string][][]string
|
||||||
@@ -35,24 +40,20 @@ type aclManager struct {
|
|||||||
entries aclEntries
|
entries aclEntries
|
||||||
optionalEntries map[string][]entry
|
optionalEntries map[string][]entry
|
||||||
ipsetStore *ipsetStore
|
ipsetStore *ipsetStore
|
||||||
|
v6 bool
|
||||||
|
|
||||||
stateManager *statemanager.Manager
|
stateManager *statemanager.Manager
|
||||||
}
|
}
|
||||||
|
|
||||||
func newAclManager(iptablesClient *iptables.IPTables, wgIface iFaceMapper) (*aclManager, error) {
|
func newAclManager(iptablesClient *iptables.IPTables, wgIface iFaceMapper) (*aclManager, error) {
|
||||||
m := &aclManager{
|
return &aclManager{
|
||||||
iptablesClient: iptablesClient,
|
iptablesClient: iptablesClient,
|
||||||
wgIface: wgIface,
|
wgIface: wgIface,
|
||||||
entries: make(map[string][][]string),
|
entries: make(map[string][][]string),
|
||||||
optionalEntries: make(map[string][]entry),
|
optionalEntries: make(map[string][]entry),
|
||||||
ipsetStore: newIpsetStore(),
|
ipsetStore: newIpsetStore(),
|
||||||
}
|
v6: iptablesClient.Proto() == iptables.ProtocolIPv6,
|
||||||
|
}, nil
|
||||||
if err := ipset.Init(); err != nil {
|
|
||||||
return nil, fmt.Errorf("init ipset: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return m, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *aclManager) init(stateManager *statemanager.Manager) error {
|
func (m *aclManager) init(stateManager *statemanager.Manager) error {
|
||||||
@@ -86,7 +87,11 @@ func (m *aclManager) AddPeerFiltering(
|
|||||||
chain := chainNameInputRules
|
chain := chainNameInputRules
|
||||||
|
|
||||||
ipsetName = transformIPsetName(ipsetName, sPort, dPort, action)
|
ipsetName = transformIPsetName(ipsetName, sPort, dPort, action)
|
||||||
specs := filterRuleSpecs(ip, string(protocol), sPort, dPort, action, ipsetName)
|
if m.v6 && ipsetName != "" {
|
||||||
|
ipsetName += "-v6"
|
||||||
|
}
|
||||||
|
proto := protoForFamily(protocol, m.v6)
|
||||||
|
specs := filterRuleSpecs(ip, proto, sPort, dPort, action, ipsetName)
|
||||||
|
|
||||||
mangleSpecs := slices.Clone(specs)
|
mangleSpecs := slices.Clone(specs)
|
||||||
mangleSpecs = append(mangleSpecs,
|
mangleSpecs = append(mangleSpecs,
|
||||||
@@ -98,8 +103,8 @@ func (m *aclManager) AddPeerFiltering(
|
|||||||
specs = append(specs, "-j", actionToStr(action))
|
specs = append(specs, "-j", actionToStr(action))
|
||||||
if ipsetName != "" {
|
if ipsetName != "" {
|
||||||
if ipList, ipsetExists := m.ipsetStore.ipset(ipsetName); ipsetExists {
|
if ipList, ipsetExists := m.ipsetStore.ipset(ipsetName); ipsetExists {
|
||||||
if err := ipset.Add(ipsetName, ip.String()); err != nil {
|
if err := m.addToIPSet(ipsetName, ip); err != nil {
|
||||||
return nil, fmt.Errorf("failed to add IP to ipset: %w", err)
|
return nil, fmt.Errorf("add IP to ipset: %w", err)
|
||||||
}
|
}
|
||||||
// if ruleset already exists it means we already have the firewall rule
|
// if ruleset already exists it means we already have the firewall rule
|
||||||
// so we need to update IPs in the ruleset and return new fw.Rule object for ACL manager.
|
// so we need to update IPs in the ruleset and return new fw.Rule object for ACL manager.
|
||||||
@@ -110,17 +115,22 @@ func (m *aclManager) AddPeerFiltering(
|
|||||||
ip: ip.String(),
|
ip: ip.String(),
|
||||||
chain: chain,
|
chain: chain,
|
||||||
specs: specs,
|
specs: specs,
|
||||||
|
v6: m.v6,
|
||||||
}}, nil
|
}}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := ipset.Flush(ipsetName); err != nil {
|
if err := m.flushIPSet(ipsetName); err != nil {
|
||||||
log.Errorf("flush ipset %s before use it: %s", ipsetName, err)
|
if errors.Is(err, ipset.ErrSetNotExist) {
|
||||||
|
log.Debugf("flush ipset %s before use: %v", ipsetName, err)
|
||||||
|
} else {
|
||||||
|
log.Errorf("flush ipset %s before use: %v", ipsetName, err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if err := ipset.Create(ipsetName); err != nil {
|
if err := m.createIPSet(ipsetName); err != nil {
|
||||||
return nil, fmt.Errorf("failed to create ipset: %w", err)
|
return nil, fmt.Errorf("create ipset: %w", err)
|
||||||
}
|
}
|
||||||
if err := ipset.Add(ipsetName, ip.String()); err != nil {
|
if err := m.addToIPSet(ipsetName, ip); err != nil {
|
||||||
return nil, fmt.Errorf("failed to add IP to ipset: %w", err)
|
return nil, fmt.Errorf("add IP to ipset: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
ipList := newIpList(ip.String())
|
ipList := newIpList(ip.String())
|
||||||
@@ -158,6 +168,7 @@ func (m *aclManager) AddPeerFiltering(
|
|||||||
ipsetName: ipsetName,
|
ipsetName: ipsetName,
|
||||||
ip: ip.String(),
|
ip: ip.String(),
|
||||||
chain: chain,
|
chain: chain,
|
||||||
|
v6: m.v6,
|
||||||
}
|
}
|
||||||
|
|
||||||
m.updateState()
|
m.updateState()
|
||||||
@@ -172,11 +183,16 @@ func (m *aclManager) DeletePeerRule(rule firewall.Rule) error {
|
|||||||
return fmt.Errorf("invalid rule type")
|
return fmt.Errorf("invalid rule type")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
shouldDestroyIpset := false
|
||||||
if ipsetList, ok := m.ipsetStore.ipset(r.ipsetName); ok {
|
if ipsetList, ok := m.ipsetStore.ipset(r.ipsetName); ok {
|
||||||
// delete IP from ruleset IPs list and ipset
|
// delete IP from ruleset IPs list and ipset
|
||||||
if _, ok := ipsetList.ips[r.ip]; ok {
|
if _, ok := ipsetList.ips[r.ip]; ok {
|
||||||
if err := ipset.Del(r.ipsetName, r.ip); err != nil {
|
ip := net.ParseIP(r.ip)
|
||||||
return fmt.Errorf("failed to delete ip from ipset: %w", err)
|
if ip == nil {
|
||||||
|
return fmt.Errorf("parse IP %s", r.ip)
|
||||||
|
}
|
||||||
|
if err := m.delFromIPSet(r.ipsetName, ip); err != nil {
|
||||||
|
return fmt.Errorf("delete ip from ipset: %w", err)
|
||||||
}
|
}
|
||||||
delete(ipsetList.ips, r.ip)
|
delete(ipsetList.ips, r.ip)
|
||||||
}
|
}
|
||||||
@@ -190,10 +206,7 @@ func (m *aclManager) DeletePeerRule(rule firewall.Rule) error {
|
|||||||
// we delete last IP from the set, that means we need to delete
|
// we delete last IP from the set, that means we need to delete
|
||||||
// set itself and associated firewall rule too
|
// set itself and associated firewall rule too
|
||||||
m.ipsetStore.deleteIpset(r.ipsetName)
|
m.ipsetStore.deleteIpset(r.ipsetName)
|
||||||
|
shouldDestroyIpset = true
|
||||||
if err := ipset.Destroy(r.ipsetName); err != nil {
|
|
||||||
log.Errorf("delete empty ipset: %v", err)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := m.iptablesClient.Delete(tableName, r.chain, r.specs...); err != nil {
|
if err := m.iptablesClient.Delete(tableName, r.chain, r.specs...); err != nil {
|
||||||
@@ -206,6 +219,16 @@ func (m *aclManager) DeletePeerRule(rule firewall.Rule) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if shouldDestroyIpset {
|
||||||
|
if err := m.destroyIPSet(r.ipsetName); err != nil {
|
||||||
|
if errors.Is(err, ipset.ErrBusy) || errors.Is(err, ipset.ErrSetNotExist) {
|
||||||
|
log.Debugf("destroy empty ipset: %v", err)
|
||||||
|
} else {
|
||||||
|
log.Errorf("destroy empty ipset: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
m.updateState()
|
m.updateState()
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
@@ -263,12 +286,26 @@ func (m *aclManager) cleanChains() error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, ipsetName := range m.ipsetStore.ipsetNames() {
|
for _, rule := range m.entries[mangleFwdKey] {
|
||||||
if err := ipset.Flush(ipsetName); err != nil {
|
if err := m.iptablesClient.DeleteIfExists(tableMangle, chainFORWARD, rule...); err != nil {
|
||||||
log.Errorf("flush ipset %q during reset: %v", ipsetName, err)
|
log.Errorf("failed to delete mangle FORWARD guard rule: %v, %s", rule, err)
|
||||||
}
|
}
|
||||||
if err := ipset.Destroy(ipsetName); err != nil {
|
}
|
||||||
log.Errorf("delete ipset %q during reset: %v", ipsetName, err)
|
|
||||||
|
for _, ipsetName := range m.ipsetStore.ipsetNames() {
|
||||||
|
if err := m.flushIPSet(ipsetName); err != nil {
|
||||||
|
if errors.Is(err, ipset.ErrSetNotExist) {
|
||||||
|
log.Debugf("flush ipset %q during reset: %v", ipsetName, err)
|
||||||
|
} else {
|
||||||
|
log.Errorf("flush ipset %q during reset: %v", ipsetName, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := m.destroyIPSet(ipsetName); err != nil {
|
||||||
|
if errors.Is(err, ipset.ErrBusy) || errors.Is(err, ipset.ErrSetNotExist) {
|
||||||
|
log.Debugf("destroy ipset %q during reset: %v", ipsetName, err)
|
||||||
|
} else {
|
||||||
|
log.Errorf("destroy ipset %q during reset: %v", ipsetName, err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
m.ipsetStore.deleteIpset(ipsetName)
|
m.ipsetStore.deleteIpset(ipsetName)
|
||||||
}
|
}
|
||||||
@@ -284,6 +321,10 @@ func (m *aclManager) createDefaultChains() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for chainName, rules := range m.entries {
|
for chainName, rules := range m.entries {
|
||||||
|
// mangle FORWARD guard rules are handled separately below
|
||||||
|
if chainName == mangleFwdKey {
|
||||||
|
continue
|
||||||
|
}
|
||||||
for _, rule := range rules {
|
for _, rule := range rules {
|
||||||
if err := m.iptablesClient.InsertUnique(tableName, chainName, 1, rule...); err != nil {
|
if err := m.iptablesClient.InsertUnique(tableName, chainName, 1, rule...); err != nil {
|
||||||
log.Debugf("failed to create input chain jump rule: %s", err)
|
log.Debugf("failed to create input chain jump rule: %s", err)
|
||||||
@@ -303,6 +344,13 @@ func (m *aclManager) createDefaultChains() error {
|
|||||||
}
|
}
|
||||||
clear(m.optionalEntries)
|
clear(m.optionalEntries)
|
||||||
|
|
||||||
|
// Insert mangle FORWARD guard rules to prevent external DNAT bypass.
|
||||||
|
for _, rule := range m.entries[mangleFwdKey] {
|
||||||
|
if err := m.iptablesClient.AppendUnique(tableMangle, chainFORWARD, rule...); err != nil {
|
||||||
|
log.Errorf("failed to add mangle FORWARD guard rule: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -324,6 +372,22 @@ func (m *aclManager) seedInitialEntries() {
|
|||||||
|
|
||||||
m.appendToEntries("FORWARD", []string{"-o", m.wgIface.Name(), "-j", chainRTFWDOUT})
|
m.appendToEntries("FORWARD", []string{"-o", m.wgIface.Name(), "-j", chainRTFWDOUT})
|
||||||
m.appendToEntries("FORWARD", []string{"-i", m.wgIface.Name(), "-j", chainRTFWDIN})
|
m.appendToEntries("FORWARD", []string{"-i", m.wgIface.Name(), "-j", chainRTFWDIN})
|
||||||
|
|
||||||
|
// Mangle FORWARD guard: when external DNAT redirects traffic from the wg interface, it
|
||||||
|
// traverses FORWARD instead of INPUT, bypassing ACL rules. ACCEPT rules in filter FORWARD
|
||||||
|
// can be inserted above ours. Mangle runs before filter, so these guard rules enforce the
|
||||||
|
// ACL mark check where it cannot be overridden.
|
||||||
|
m.appendToEntries(mangleFwdKey, []string{
|
||||||
|
"-i", m.wgIface.Name(),
|
||||||
|
"-m", "conntrack", "--ctstate", "RELATED,ESTABLISHED",
|
||||||
|
"-j", "ACCEPT",
|
||||||
|
})
|
||||||
|
m.appendToEntries(mangleFwdKey, []string{
|
||||||
|
"-i", m.wgIface.Name(),
|
||||||
|
"-m", "conntrack", "--ctstate", "DNAT",
|
||||||
|
"-m", "mark", "!", "--mark", fmt.Sprintf("%#x", nbnet.PreroutingFwmarkRedirected),
|
||||||
|
"-j", "DROP",
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *aclManager) seedInitialOptionalEntries() {
|
func (m *aclManager) seedInitialOptionalEntries() {
|
||||||
@@ -357,8 +421,13 @@ func (m *aclManager) updateState() {
|
|||||||
currentState.Lock()
|
currentState.Lock()
|
||||||
defer currentState.Unlock()
|
defer currentState.Unlock()
|
||||||
|
|
||||||
currentState.ACLEntries = m.entries
|
if m.v6 {
|
||||||
currentState.ACLIPsetStore = m.ipsetStore
|
currentState.ACLEntries6 = m.entries
|
||||||
|
currentState.ACLIPsetStore6 = m.ipsetStore
|
||||||
|
} else {
|
||||||
|
currentState.ACLEntries = m.entries
|
||||||
|
currentState.ACLIPsetStore = m.ipsetStore
|
||||||
|
}
|
||||||
|
|
||||||
if err := m.stateManager.UpdateState(currentState); err != nil {
|
if err := m.stateManager.UpdateState(currentState); err != nil {
|
||||||
log.Errorf("failed to update state: %v", err)
|
log.Errorf("failed to update state: %v", err)
|
||||||
@@ -366,16 +435,22 @@ func (m *aclManager) updateState() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// filterRuleSpecs returns the specs of a filtering rule
|
// filterRuleSpecs returns the specs of a filtering rule
|
||||||
func filterRuleSpecs(ip net.IP, protocol string, sPort, dPort *firewall.Port, action firewall.Action, ipsetName string) (specs []string) {
|
// protoForFamily translates ICMP to ICMPv6 for ip6tables.
|
||||||
matchByIP := true
|
// ip6tables requires "ipv6-icmp" (or "icmpv6") instead of "icmp".
|
||||||
// don't use IP matching if IP is ip 0.0.0.0
|
func protoForFamily(protocol firewall.Protocol, v6 bool) string {
|
||||||
if ip.String() == "0.0.0.0" {
|
if v6 && protocol == firewall.ProtocolICMP {
|
||||||
matchByIP = false
|
return "ipv6-icmp"
|
||||||
}
|
}
|
||||||
|
return string(protocol)
|
||||||
|
}
|
||||||
|
|
||||||
|
func filterRuleSpecs(ip net.IP, protocol string, sPort, dPort *firewall.Port, action firewall.Action, ipsetName string) (specs []string) {
|
||||||
|
// don't use IP matching if IP is 0.0.0.0
|
||||||
|
matchByIP := !ip.IsUnspecified()
|
||||||
|
|
||||||
if matchByIP {
|
if matchByIP {
|
||||||
if ipsetName != "" {
|
if ipsetName != "" {
|
||||||
specs = append(specs, "-m", "set", "--set", ipsetName, "src")
|
specs = append(specs, "-m", "set", "--match-set", ipsetName, "src")
|
||||||
} else {
|
} else {
|
||||||
specs = append(specs, "-s", ip.String())
|
specs = append(specs, "-s", ip.String())
|
||||||
}
|
}
|
||||||
@@ -416,3 +491,64 @@ func transformIPsetName(ipsetName string, sPort, dPort *firewall.Port, action fi
|
|||||||
return ipsetName + actionSuffix
|
return ipsetName + actionSuffix
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *aclManager) createIPSet(name string) error {
|
||||||
|
opts := ipset.CreateOptions{
|
||||||
|
Replace: true,
|
||||||
|
}
|
||||||
|
if m.v6 {
|
||||||
|
opts.Family = ipset.FamilyIPV6
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := ipset.Create(name, ipset.TypeHashNet, opts); err != nil {
|
||||||
|
return fmt.Errorf("create ipset %s: %w", name, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Debugf("created ipset %s with type hash:net", name)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *aclManager) addToIPSet(name string, ip net.IP) error {
|
||||||
|
cidr := uint8(32)
|
||||||
|
if ip.To4() == nil {
|
||||||
|
cidr = 128
|
||||||
|
}
|
||||||
|
|
||||||
|
entry := &ipset.Entry{
|
||||||
|
IP: ip,
|
||||||
|
CIDR: cidr,
|
||||||
|
Replace: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := ipset.Add(name, entry); err != nil {
|
||||||
|
return fmt.Errorf("add IP to ipset %s: %w", name, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *aclManager) delFromIPSet(name string, ip net.IP) error {
|
||||||
|
cidr := uint8(32)
|
||||||
|
if ip.To4() == nil {
|
||||||
|
cidr = 128
|
||||||
|
}
|
||||||
|
|
||||||
|
entry := &ipset.Entry{
|
||||||
|
IP: ip,
|
||||||
|
CIDR: cidr,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := ipset.Del(name, entry); err != nil {
|
||||||
|
return fmt.Errorf("delete IP from ipset %s: %w", name, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *aclManager) flushIPSet(name string) error {
|
||||||
|
return ipset.Flush(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *aclManager) destroyIPSet(name string) error {
|
||||||
|
return ipset.Destroy(name)
|
||||||
|
}
|
||||||
|
|||||||
@@ -12,27 +12,37 @@ import (
|
|||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
|
|
||||||
nberrors "github.com/netbirdio/netbird/client/errors"
|
nberrors "github.com/netbirdio/netbird/client/errors"
|
||||||
|
"github.com/netbirdio/netbird/client/firewall/firewalld"
|
||||||
firewall "github.com/netbirdio/netbird/client/firewall/manager"
|
firewall "github.com/netbirdio/netbird/client/firewall/manager"
|
||||||
"github.com/netbirdio/netbird/client/iface/wgaddr"
|
"github.com/netbirdio/netbird/client/iface/wgaddr"
|
||||||
"github.com/netbirdio/netbird/client/internal/statemanager"
|
"github.com/netbirdio/netbird/client/internal/statemanager"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type resetter interface {
|
||||||
|
Reset() error
|
||||||
|
}
|
||||||
|
|
||||||
// Manager of iptables firewall
|
// Manager of iptables firewall
|
||||||
type Manager struct {
|
type Manager struct {
|
||||||
mutex sync.Mutex
|
mutex sync.Mutex
|
||||||
|
|
||||||
wgIface iFaceMapper
|
wgIface iFaceMapper
|
||||||
|
|
||||||
ipv4Client *iptables.IPTables
|
ipv4Client *iptables.IPTables
|
||||||
aclMgr *aclManager
|
aclMgr *aclManager
|
||||||
router *router
|
router *router
|
||||||
|
rawSupported bool
|
||||||
|
|
||||||
|
// IPv6 counterparts, nil when no v6 overlay
|
||||||
|
ipv6Client *iptables.IPTables
|
||||||
|
aclMgr6 *aclManager
|
||||||
|
router6 *router
|
||||||
}
|
}
|
||||||
|
|
||||||
// iFaceMapper defines subset methods of interface required for manager
|
// iFaceMapper defines subset methods of interface required for manager
|
||||||
type iFaceMapper interface {
|
type iFaceMapper interface {
|
||||||
Name() string
|
Name() string
|
||||||
Address() wgaddr.Address
|
Address() wgaddr.Address
|
||||||
IsUserspaceBind() bool
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create iptables firewall manager
|
// Create iptables firewall manager
|
||||||
@@ -57,16 +67,49 @@ func Create(wgIface iFaceMapper, mtu uint16) (*Manager, error) {
|
|||||||
return nil, fmt.Errorf("create acl manager: %w", err)
|
return nil, fmt.Errorf("create acl manager: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if wgIface.Address().HasIPv6() {
|
||||||
|
if err := m.createIPv6Components(wgIface, mtu); err != nil {
|
||||||
|
return nil, fmt.Errorf("create IPv6 firewall: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *Manager) createIPv6Components(wgIface iFaceMapper, mtu uint16) error {
|
||||||
|
ip6Client, err := iptables.NewWithProtocol(iptables.ProtocolIPv6)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("init ip6tables: %w", err)
|
||||||
|
}
|
||||||
|
m.ipv6Client = ip6Client
|
||||||
|
|
||||||
|
m.router6, err = newRouter(ip6Client, wgIface, mtu)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("create v6 router: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Share the same IP forwarding state with the v4 router, since
|
||||||
|
// EnableIPForwarding controls both v4 and v6 sysctls.
|
||||||
|
m.router6.ipFwdState = m.router.ipFwdState
|
||||||
|
|
||||||
|
m.aclMgr6, err = newAclManager(ip6Client, wgIface)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("create v6 acl manager: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) hasIPv6() bool {
|
||||||
|
return m.ipv6Client != nil
|
||||||
|
}
|
||||||
|
|
||||||
func (m *Manager) Init(stateManager *statemanager.Manager) error {
|
func (m *Manager) Init(stateManager *statemanager.Manager) error {
|
||||||
state := &ShutdownState{
|
state := &ShutdownState{
|
||||||
InterfaceState: &InterfaceState{
|
InterfaceState: &InterfaceState{
|
||||||
NameStr: m.wgIface.Name(),
|
NameStr: m.wgIface.Name(),
|
||||||
WGAddress: m.wgIface.Address(),
|
WGAddress: m.wgIface.Address(),
|
||||||
UserspaceBind: m.wgIface.IsUserspaceBind(),
|
MTU: m.router.mtu,
|
||||||
MTU: m.router.mtu,
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
stateManager.RegisterState(state)
|
stateManager.RegisterState(state)
|
||||||
@@ -74,13 +117,18 @@ func (m *Manager) Init(stateManager *statemanager.Manager) error {
|
|||||||
log.Errorf("failed to update state: %v", err)
|
log.Errorf("failed to update state: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := m.router.init(stateManager); err != nil {
|
if err := m.initChains(stateManager); err != nil {
|
||||||
return fmt.Errorf("router init: %w", err)
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := m.aclMgr.init(stateManager); err != nil {
|
if err := m.initNoTrackChain(); err != nil {
|
||||||
// TODO: cleanup router
|
log.Warnf("raw table not available, notrack rules will be disabled: %v", err)
|
||||||
return fmt.Errorf("acl manager init: %w", err)
|
}
|
||||||
|
|
||||||
|
// Trust after all fatal init steps so a later failure doesn't leave the
|
||||||
|
// interface in firewalld's trusted zone without a corresponding Close.
|
||||||
|
if err := firewalld.TrustInterface(m.wgIface.Name()); err != nil {
|
||||||
|
log.Warnf("failed to trust interface in firewalld: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// persist early to ensure cleanup of chains
|
// persist early to ensure cleanup of chains
|
||||||
@@ -93,6 +141,41 @@ func (m *Manager) Init(stateManager *statemanager.Manager) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// initChains initializes router and ACL chains for both address families,
|
||||||
|
// rolling back on failure.
|
||||||
|
func (m *Manager) initChains(stateManager *statemanager.Manager) error {
|
||||||
|
type initStep struct {
|
||||||
|
name string
|
||||||
|
init func(*statemanager.Manager) error
|
||||||
|
mgr resetter
|
||||||
|
}
|
||||||
|
|
||||||
|
steps := []initStep{
|
||||||
|
{"router", m.router.init, m.router},
|
||||||
|
{"acl manager", m.aclMgr.init, m.aclMgr},
|
||||||
|
}
|
||||||
|
if m.hasIPv6() {
|
||||||
|
steps = append(steps,
|
||||||
|
initStep{"v6 router", m.router6.init, m.router6},
|
||||||
|
initStep{"v6 acl manager", m.aclMgr6.init, m.aclMgr6},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
var initialized []initStep
|
||||||
|
for _, s := range steps {
|
||||||
|
if err := s.init(stateManager); err != nil {
|
||||||
|
for i := len(initialized) - 1; i >= 0; i-- {
|
||||||
|
if rerr := initialized[i].mgr.Reset(); rerr != nil {
|
||||||
|
log.Warnf("rollback %s: %v", initialized[i].name, rerr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fmt.Errorf("%s init: %w", s.name, err)
|
||||||
|
}
|
||||||
|
initialized = append(initialized, s)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// AddPeerFiltering adds a rule to the firewall
|
// AddPeerFiltering adds a rule to the firewall
|
||||||
//
|
//
|
||||||
// Comment will be ignored because some system this feature is not supported
|
// Comment will be ignored because some system this feature is not supported
|
||||||
@@ -108,7 +191,13 @@ func (m *Manager) AddPeerFiltering(
|
|||||||
m.mutex.Lock()
|
m.mutex.Lock()
|
||||||
defer m.mutex.Unlock()
|
defer m.mutex.Unlock()
|
||||||
|
|
||||||
return m.aclMgr.AddPeerFiltering(id, ip, proto, sPort, dPort, action, ipsetName)
|
if ip.To4() != nil {
|
||||||
|
return m.aclMgr.AddPeerFiltering(id, ip, proto, sPort, dPort, action, ipsetName)
|
||||||
|
}
|
||||||
|
if !m.hasIPv6() {
|
||||||
|
return nil, fmt.Errorf("add peer filtering for %s: %w", ip, firewall.ErrIPv6NotInitialized)
|
||||||
|
}
|
||||||
|
return m.aclMgr6.AddPeerFiltering(id, ip, proto, sPort, dPort, action, ipsetName)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) AddRouteFiltering(
|
func (m *Manager) AddRouteFiltering(
|
||||||
@@ -122,25 +211,48 @@ func (m *Manager) AddRouteFiltering(
|
|||||||
m.mutex.Lock()
|
m.mutex.Lock()
|
||||||
defer m.mutex.Unlock()
|
defer m.mutex.Unlock()
|
||||||
|
|
||||||
if destination.IsPrefix() && !destination.Prefix.Addr().Is4() {
|
if isIPv6RouteRule(sources, destination) {
|
||||||
return nil, fmt.Errorf("unsupported IP version: %s", destination.Prefix.Addr().String())
|
if !m.hasIPv6() {
|
||||||
|
return nil, fmt.Errorf("add route filtering: %w", firewall.ErrIPv6NotInitialized)
|
||||||
|
}
|
||||||
|
return m.router6.AddRouteFiltering(id, sources, destination, proto, sPort, dPort, action)
|
||||||
}
|
}
|
||||||
|
|
||||||
return m.router.AddRouteFiltering(id, sources, destination, proto, sPort, dPort, action)
|
return m.router.AddRouteFiltering(id, sources, destination, proto, sPort, dPort, action)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func isIPv6RouteRule(sources []netip.Prefix, destination firewall.Network) bool {
|
||||||
|
if destination.IsPrefix() {
|
||||||
|
return destination.Prefix.Addr().Is6()
|
||||||
|
}
|
||||||
|
return len(sources) > 0 && sources[0].Addr().Is6()
|
||||||
|
}
|
||||||
|
|
||||||
// DeletePeerRule from the firewall by rule definition
|
// DeletePeerRule from the firewall by rule definition
|
||||||
func (m *Manager) DeletePeerRule(rule firewall.Rule) error {
|
func (m *Manager) DeletePeerRule(rule firewall.Rule) error {
|
||||||
m.mutex.Lock()
|
m.mutex.Lock()
|
||||||
defer m.mutex.Unlock()
|
defer m.mutex.Unlock()
|
||||||
|
|
||||||
|
if m.hasIPv6() && isIPv6IptRule(rule) {
|
||||||
|
return m.aclMgr6.DeletePeerRule(rule)
|
||||||
|
}
|
||||||
return m.aclMgr.DeletePeerRule(rule)
|
return m.aclMgr.DeletePeerRule(rule)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func isIPv6IptRule(rule firewall.Rule) bool {
|
||||||
|
r, ok := rule.(*Rule)
|
||||||
|
return ok && r.v6
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteRouteRule deletes a routing rule.
|
||||||
|
// Route rules are keyed by content hash. Check v4 first, try v6 if not found.
|
||||||
func (m *Manager) DeleteRouteRule(rule firewall.Rule) error {
|
func (m *Manager) DeleteRouteRule(rule firewall.Rule) error {
|
||||||
m.mutex.Lock()
|
m.mutex.Lock()
|
||||||
defer m.mutex.Unlock()
|
defer m.mutex.Unlock()
|
||||||
|
|
||||||
|
if m.hasIPv6() && !m.router.hasRule(rule.ID()) {
|
||||||
|
return m.router6.DeleteRouteRule(rule)
|
||||||
|
}
|
||||||
return m.router.DeleteRouteRule(rule)
|
return m.router.DeleteRouteRule(rule)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -156,18 +268,65 @@ func (m *Manager) AddNatRule(pair firewall.RouterPair) error {
|
|||||||
m.mutex.Lock()
|
m.mutex.Lock()
|
||||||
defer m.mutex.Unlock()
|
defer m.mutex.Unlock()
|
||||||
|
|
||||||
return m.router.AddNatRule(pair)
|
if pair.Destination.IsPrefix() && pair.Destination.Prefix.Addr().Is6() {
|
||||||
|
if !m.hasIPv6() {
|
||||||
|
return fmt.Errorf("add NAT rule: %w", firewall.ErrIPv6NotInitialized)
|
||||||
|
}
|
||||||
|
return m.router6.AddNatRule(pair)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := m.router.AddNatRule(pair); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dynamic routes need NAT in both tables since resolved IPs can be
|
||||||
|
// either v4 or v6. This covers both DomainSet (modern) and the legacy
|
||||||
|
// wildcard 0.0.0.0/0 destination where the client resolves DNS.
|
||||||
|
if m.hasIPv6() && pair.Dynamic {
|
||||||
|
v6Pair := firewall.ToV6NatPair(pair)
|
||||||
|
if err := m.router6.AddNatRule(v6Pair); err != nil {
|
||||||
|
return fmt.Errorf("add v6 NAT rule: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) RemoveNatRule(pair firewall.RouterPair) error {
|
func (m *Manager) RemoveNatRule(pair firewall.RouterPair) error {
|
||||||
m.mutex.Lock()
|
m.mutex.Lock()
|
||||||
defer m.mutex.Unlock()
|
defer m.mutex.Unlock()
|
||||||
|
|
||||||
return m.router.RemoveNatRule(pair)
|
if pair.Destination.IsPrefix() && pair.Destination.Prefix.Addr().Is6() {
|
||||||
|
if !m.hasIPv6() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return m.router6.RemoveNatRule(pair)
|
||||||
|
}
|
||||||
|
|
||||||
|
var merr *multierror.Error
|
||||||
|
|
||||||
|
if err := m.router.RemoveNatRule(pair); err != nil {
|
||||||
|
merr = multierror.Append(merr, fmt.Errorf("remove v4 NAT rule: %w", err))
|
||||||
|
}
|
||||||
|
|
||||||
|
if m.hasIPv6() && pair.Dynamic {
|
||||||
|
v6Pair := firewall.ToV6NatPair(pair)
|
||||||
|
if err := m.router6.RemoveNatRule(v6Pair); err != nil {
|
||||||
|
merr = multierror.Append(merr, fmt.Errorf("remove v6 NAT rule: %w", err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nberrors.FormatErrorOrNil(merr)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) SetLegacyManagement(isLegacy bool) error {
|
func (m *Manager) SetLegacyManagement(isLegacy bool) error {
|
||||||
return firewall.SetLegacyManagement(m.router, isLegacy)
|
if err := firewall.SetLegacyManagement(m.router, isLegacy); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if m.hasIPv6() {
|
||||||
|
return firewall.SetLegacyManagement(m.router6, isLegacy)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reset firewall to the default state
|
// Reset firewall to the default state
|
||||||
@@ -177,6 +336,19 @@ func (m *Manager) Close(stateManager *statemanager.Manager) error {
|
|||||||
|
|
||||||
var merr *multierror.Error
|
var merr *multierror.Error
|
||||||
|
|
||||||
|
if err := m.cleanupNoTrackChain(); err != nil {
|
||||||
|
merr = multierror.Append(merr, fmt.Errorf("cleanup notrack chain: %w", err))
|
||||||
|
}
|
||||||
|
|
||||||
|
if m.hasIPv6() {
|
||||||
|
if err := m.aclMgr6.Reset(); err != nil {
|
||||||
|
merr = multierror.Append(merr, fmt.Errorf("reset v6 acl manager: %w", err))
|
||||||
|
}
|
||||||
|
if err := m.router6.Reset(); err != nil {
|
||||||
|
merr = multierror.Append(merr, fmt.Errorf("reset v6 router: %w", err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if err := m.aclMgr.Reset(); err != nil {
|
if err := m.aclMgr.Reset(); err != nil {
|
||||||
merr = multierror.Append(merr, fmt.Errorf("reset acl manager: %w", err))
|
merr = multierror.Append(merr, fmt.Errorf("reset acl manager: %w", err))
|
||||||
}
|
}
|
||||||
@@ -184,6 +356,12 @@ func (m *Manager) Close(stateManager *statemanager.Manager) error {
|
|||||||
merr = multierror.Append(merr, fmt.Errorf("reset router: %w", err))
|
merr = multierror.Append(merr, fmt.Errorf("reset router: %w", err))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Appending to merr intentionally blocks DeleteState below so ShutdownState
|
||||||
|
// stays persisted and the crash-recovery path retries firewalld cleanup.
|
||||||
|
if err := firewalld.UntrustInterface(m.wgIface.Name()); err != nil {
|
||||||
|
merr = multierror.Append(merr, err)
|
||||||
|
}
|
||||||
|
|
||||||
// attempt to delete state only if all other operations succeeded
|
// attempt to delete state only if all other operations succeeded
|
||||||
if merr == nil {
|
if merr == nil {
|
||||||
if err := stateManager.DeleteState(&ShutdownState{}); err != nil {
|
if err := stateManager.DeleteState(&ShutdownState{}); err != nil {
|
||||||
@@ -194,25 +372,25 @@ func (m *Manager) Close(stateManager *statemanager.Manager) error {
|
|||||||
return nberrors.FormatErrorOrNil(merr)
|
return nberrors.FormatErrorOrNil(merr)
|
||||||
}
|
}
|
||||||
|
|
||||||
// AllowNetbird allows netbird interface traffic
|
// AllowNetbird allows netbird interface traffic.
|
||||||
|
// This is called when USPFilter wraps the native firewall, adding blanket accept
|
||||||
|
// rules so that packet filtering is handled in userspace instead of by netfilter.
|
||||||
func (m *Manager) AllowNetbird() error {
|
func (m *Manager) AllowNetbird() error {
|
||||||
if !m.wgIface.IsUserspaceBind() {
|
var merr *multierror.Error
|
||||||
return nil
|
if _, err := m.AddPeerFiltering(nil, net.IP{0, 0, 0, 0}, firewall.ProtocolALL, nil, nil, firewall.ActionAccept, ""); err != nil {
|
||||||
|
merr = multierror.Append(merr, fmt.Errorf("allow netbird v4 interface traffic: %w", err))
|
||||||
|
}
|
||||||
|
if m.hasIPv6() {
|
||||||
|
if _, err := m.AddPeerFiltering(nil, net.IPv6zero, firewall.ProtocolALL, nil, nil, firewall.ActionAccept, ""); err != nil {
|
||||||
|
merr = multierror.Append(merr, fmt.Errorf("allow netbird v6 interface traffic: %w", err))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err := m.AddPeerFiltering(
|
if err := firewalld.TrustInterface(m.wgIface.Name()); err != nil {
|
||||||
nil,
|
log.Warnf("failed to trust interface in firewalld: %v", err)
|
||||||
net.IP{0, 0, 0, 0},
|
|
||||||
firewall.ProtocolALL,
|
|
||||||
nil,
|
|
||||||
nil,
|
|
||||||
firewall.ActionAccept,
|
|
||||||
"",
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("allow netbird interface traffic: %w", err)
|
|
||||||
}
|
}
|
||||||
return nil
|
|
||||||
|
return nberrors.FormatErrorOrNil(merr)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Flush doesn't need to be implemented for this manager
|
// Flush doesn't need to be implemented for this manager
|
||||||
@@ -242,6 +420,12 @@ func (m *Manager) AddDNATRule(rule firewall.ForwardRule) (firewall.Rule, error)
|
|||||||
m.mutex.Lock()
|
m.mutex.Lock()
|
||||||
defer m.mutex.Unlock()
|
defer m.mutex.Unlock()
|
||||||
|
|
||||||
|
if rule.TranslatedAddress.Is6() {
|
||||||
|
if !m.hasIPv6() {
|
||||||
|
return nil, fmt.Errorf("add DNAT rule: %w", firewall.ErrIPv6NotInitialized)
|
||||||
|
}
|
||||||
|
return m.router6.AddDNATRule(rule)
|
||||||
|
}
|
||||||
return m.router.AddDNATRule(rule)
|
return m.router.AddDNATRule(rule)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -250,6 +434,9 @@ func (m *Manager) DeleteDNATRule(rule firewall.Rule) error {
|
|||||||
m.mutex.Lock()
|
m.mutex.Lock()
|
||||||
defer m.mutex.Unlock()
|
defer m.mutex.Unlock()
|
||||||
|
|
||||||
|
if m.hasIPv6() && !m.router.hasRule(rule.ID()+dnatSuffix) {
|
||||||
|
return m.router6.DeleteDNATRule(rule)
|
||||||
|
}
|
||||||
return m.router.DeleteDNATRule(rule)
|
return m.router.DeleteDNATRule(rule)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -258,23 +445,210 @@ func (m *Manager) UpdateSet(set firewall.Set, prefixes []netip.Prefix) error {
|
|||||||
m.mutex.Lock()
|
m.mutex.Lock()
|
||||||
defer m.mutex.Unlock()
|
defer m.mutex.Unlock()
|
||||||
|
|
||||||
return m.router.UpdateSet(set, prefixes)
|
var v4Prefixes, v6Prefixes []netip.Prefix
|
||||||
|
for _, p := range prefixes {
|
||||||
|
if p.Addr().Is6() {
|
||||||
|
v6Prefixes = append(v6Prefixes, p)
|
||||||
|
} else {
|
||||||
|
v4Prefixes = append(v4Prefixes, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := m.router.UpdateSet(set, v4Prefixes); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if m.hasIPv6() && len(v6Prefixes) > 0 {
|
||||||
|
if err := m.router6.UpdateSet(set, v6Prefixes); err != nil {
|
||||||
|
return fmt.Errorf("update v6 set: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// AddInboundDNAT adds an inbound DNAT rule redirecting traffic from NetBird peers to local services.
|
// AddInboundDNAT adds an inbound DNAT rule redirecting traffic from NetBird peers to local services.
|
||||||
func (m *Manager) AddInboundDNAT(localAddr netip.Addr, protocol firewall.Protocol, sourcePort, targetPort uint16) error {
|
func (m *Manager) AddInboundDNAT(localAddr netip.Addr, protocol firewall.Protocol, originalPort, translatedPort uint16) error {
|
||||||
m.mutex.Lock()
|
m.mutex.Lock()
|
||||||
defer m.mutex.Unlock()
|
defer m.mutex.Unlock()
|
||||||
|
|
||||||
return m.router.AddInboundDNAT(localAddr, protocol, sourcePort, targetPort)
|
if localAddr.Is6() {
|
||||||
|
if !m.hasIPv6() {
|
||||||
|
return fmt.Errorf("add inbound DNAT: %w", firewall.ErrIPv6NotInitialized)
|
||||||
|
}
|
||||||
|
return m.router6.AddInboundDNAT(localAddr, protocol, originalPort, translatedPort)
|
||||||
|
}
|
||||||
|
return m.router.AddInboundDNAT(localAddr, protocol, originalPort, translatedPort)
|
||||||
}
|
}
|
||||||
|
|
||||||
// RemoveInboundDNAT removes an inbound DNAT rule.
|
// RemoveInboundDNAT removes an inbound DNAT rule.
|
||||||
func (m *Manager) RemoveInboundDNAT(localAddr netip.Addr, protocol firewall.Protocol, sourcePort, targetPort uint16) error {
|
func (m *Manager) RemoveInboundDNAT(localAddr netip.Addr, protocol firewall.Protocol, originalPort, translatedPort uint16) error {
|
||||||
m.mutex.Lock()
|
m.mutex.Lock()
|
||||||
defer m.mutex.Unlock()
|
defer m.mutex.Unlock()
|
||||||
|
|
||||||
return m.router.RemoveInboundDNAT(localAddr, protocol, sourcePort, targetPort)
|
if localAddr.Is6() {
|
||||||
|
if !m.hasIPv6() {
|
||||||
|
return fmt.Errorf("remove inbound DNAT: %w", firewall.ErrIPv6NotInitialized)
|
||||||
|
}
|
||||||
|
return m.router6.RemoveInboundDNAT(localAddr, protocol, originalPort, translatedPort)
|
||||||
|
}
|
||||||
|
return m.router.RemoveInboundDNAT(localAddr, protocol, originalPort, translatedPort)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddOutputDNAT adds an OUTPUT chain DNAT rule for locally-generated traffic.
|
||||||
|
func (m *Manager) AddOutputDNAT(localAddr netip.Addr, protocol firewall.Protocol, originalPort, translatedPort uint16) error {
|
||||||
|
m.mutex.Lock()
|
||||||
|
defer m.mutex.Unlock()
|
||||||
|
|
||||||
|
if localAddr.Is6() {
|
||||||
|
if !m.hasIPv6() {
|
||||||
|
return fmt.Errorf("add output DNAT: %w", firewall.ErrIPv6NotInitialized)
|
||||||
|
}
|
||||||
|
return m.router6.AddOutputDNAT(localAddr, protocol, originalPort, translatedPort)
|
||||||
|
}
|
||||||
|
return m.router.AddOutputDNAT(localAddr, protocol, originalPort, translatedPort)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoveOutputDNAT removes an OUTPUT chain DNAT rule.
|
||||||
|
func (m *Manager) RemoveOutputDNAT(localAddr netip.Addr, protocol firewall.Protocol, originalPort, translatedPort uint16) error {
|
||||||
|
m.mutex.Lock()
|
||||||
|
defer m.mutex.Unlock()
|
||||||
|
|
||||||
|
if localAddr.Is6() {
|
||||||
|
if !m.hasIPv6() {
|
||||||
|
return fmt.Errorf("remove output DNAT: %w", firewall.ErrIPv6NotInitialized)
|
||||||
|
}
|
||||||
|
return m.router6.RemoveOutputDNAT(localAddr, protocol, originalPort, translatedPort)
|
||||||
|
}
|
||||||
|
return m.router.RemoveOutputDNAT(localAddr, protocol, originalPort, translatedPort)
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
chainNameRaw = "NETBIRD-RAW"
|
||||||
|
chainOUTPUT = "OUTPUT"
|
||||||
|
tableRaw = "raw"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SetupEBPFProxyNoTrack creates notrack rules for eBPF proxy loopback traffic.
|
||||||
|
// This prevents conntrack from tracking WireGuard proxy traffic on loopback, which
|
||||||
|
// can interfere with MASQUERADE rules (e.g., from container runtimes like Podman/netavark).
|
||||||
|
//
|
||||||
|
// Traffic flows that need NOTRACK:
|
||||||
|
//
|
||||||
|
// 1. Egress: WireGuard -> fake endpoint (before eBPF rewrite)
|
||||||
|
// src=127.0.0.1:wgPort -> dst=127.0.0.1:fakePort
|
||||||
|
// Matched by: sport=wgPort
|
||||||
|
//
|
||||||
|
// 2. Egress: Proxy -> WireGuard (via raw socket)
|
||||||
|
// src=127.0.0.1:fakePort -> dst=127.0.0.1:wgPort
|
||||||
|
// Matched by: dport=wgPort
|
||||||
|
//
|
||||||
|
// 3. Ingress: Packets to WireGuard
|
||||||
|
// dst=127.0.0.1:wgPort
|
||||||
|
// Matched by: dport=wgPort
|
||||||
|
//
|
||||||
|
// 4. Ingress: Packets to proxy (after eBPF rewrite)
|
||||||
|
// dst=127.0.0.1:proxyPort
|
||||||
|
// Matched by: dport=proxyPort
|
||||||
|
//
|
||||||
|
// Rules are cleaned up when the firewall manager is closed.
|
||||||
|
func (m *Manager) SetupEBPFProxyNoTrack(proxyPort, wgPort uint16) error {
|
||||||
|
m.mutex.Lock()
|
||||||
|
defer m.mutex.Unlock()
|
||||||
|
|
||||||
|
if !m.rawSupported {
|
||||||
|
return fmt.Errorf("raw table not available")
|
||||||
|
}
|
||||||
|
|
||||||
|
wgPortStr := fmt.Sprintf("%d", wgPort)
|
||||||
|
proxyPortStr := fmt.Sprintf("%d", proxyPort)
|
||||||
|
|
||||||
|
// Egress rules: match outgoing loopback UDP packets
|
||||||
|
outputRuleSport := []string{"-o", "lo", "-s", "127.0.0.1", "-d", "127.0.0.1", "-p", "udp", "--sport", wgPortStr, "-j", "NOTRACK"}
|
||||||
|
if err := m.ipv4Client.AppendUnique(tableRaw, chainNameRaw, outputRuleSport...); err != nil {
|
||||||
|
return fmt.Errorf("add output sport notrack rule: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
outputRuleDport := []string{"-o", "lo", "-s", "127.0.0.1", "-d", "127.0.0.1", "-p", "udp", "--dport", wgPortStr, "-j", "NOTRACK"}
|
||||||
|
if err := m.ipv4Client.AppendUnique(tableRaw, chainNameRaw, outputRuleDport...); err != nil {
|
||||||
|
return fmt.Errorf("add output dport notrack rule: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ingress rules: match incoming loopback UDP packets
|
||||||
|
preroutingRuleWg := []string{"-i", "lo", "-s", "127.0.0.1", "-d", "127.0.0.1", "-p", "udp", "--dport", wgPortStr, "-j", "NOTRACK"}
|
||||||
|
if err := m.ipv4Client.AppendUnique(tableRaw, chainNameRaw, preroutingRuleWg...); err != nil {
|
||||||
|
return fmt.Errorf("add prerouting wg notrack rule: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
preroutingRuleProxy := []string{"-i", "lo", "-s", "127.0.0.1", "-d", "127.0.0.1", "-p", "udp", "--dport", proxyPortStr, "-j", "NOTRACK"}
|
||||||
|
if err := m.ipv4Client.AppendUnique(tableRaw, chainNameRaw, preroutingRuleProxy...); err != nil {
|
||||||
|
return fmt.Errorf("add prerouting proxy notrack rule: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Debugf("set up ebpf proxy notrack rules for ports %d,%d", proxyPort, wgPort)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) initNoTrackChain() error {
|
||||||
|
if err := m.cleanupNoTrackChain(); err != nil {
|
||||||
|
log.Debugf("cleanup notrack chain: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := m.ipv4Client.NewChain(tableRaw, chainNameRaw); err != nil {
|
||||||
|
return fmt.Errorf("create chain: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
jumpRule := []string{"-j", chainNameRaw}
|
||||||
|
|
||||||
|
if err := m.ipv4Client.InsertUnique(tableRaw, chainOUTPUT, 1, jumpRule...); err != nil {
|
||||||
|
if delErr := m.ipv4Client.DeleteChain(tableRaw, chainNameRaw); delErr != nil {
|
||||||
|
log.Debugf("delete orphan chain: %v", delErr)
|
||||||
|
}
|
||||||
|
return fmt.Errorf("add output jump rule: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := m.ipv4Client.InsertUnique(tableRaw, chainPREROUTING, 1, jumpRule...); err != nil {
|
||||||
|
if delErr := m.ipv4Client.DeleteIfExists(tableRaw, chainOUTPUT, jumpRule...); delErr != nil {
|
||||||
|
log.Debugf("delete output jump rule: %v", delErr)
|
||||||
|
}
|
||||||
|
if delErr := m.ipv4Client.DeleteChain(tableRaw, chainNameRaw); delErr != nil {
|
||||||
|
log.Debugf("delete orphan chain: %v", delErr)
|
||||||
|
}
|
||||||
|
return fmt.Errorf("add prerouting jump rule: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
m.rawSupported = true
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) cleanupNoTrackChain() error {
|
||||||
|
exists, err := m.ipv4Client.ChainExists(tableRaw, chainNameRaw)
|
||||||
|
if err != nil {
|
||||||
|
if !m.rawSupported {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return fmt.Errorf("check chain exists: %w", err)
|
||||||
|
}
|
||||||
|
if !exists {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
jumpRule := []string{"-j", chainNameRaw}
|
||||||
|
|
||||||
|
if err := m.ipv4Client.DeleteIfExists(tableRaw, chainOUTPUT, jumpRule...); err != nil {
|
||||||
|
return fmt.Errorf("remove output jump rule: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := m.ipv4Client.DeleteIfExists(tableRaw, chainPREROUTING, jumpRule...); err != nil {
|
||||||
|
return fmt.Errorf("remove prerouting jump rule: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := m.ipv4Client.ClearAndDeleteChain(tableRaw, chainNameRaw); err != nil {
|
||||||
|
return fmt.Errorf("clear and delete chain: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
m.rawSupported = false
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func getConntrackEstablished() []string {
|
func getConntrackEstablished() []string {
|
||||||
|
|||||||
@@ -47,8 +47,6 @@ func (i *iFaceMock) Address() wgaddr.Address {
|
|||||||
panic("AddressFunc is not set")
|
panic("AddressFunc is not set")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (i *iFaceMock) IsUserspaceBind() bool { return false }
|
|
||||||
|
|
||||||
func TestIptablesManager(t *testing.T) {
|
func TestIptablesManager(t *testing.T) {
|
||||||
ipv4Client, err := iptables.NewWithProtocol(iptables.ProtocolIPv4)
|
ipv4Client, err := iptables.NewWithProtocol(iptables.ProtocolIPv4)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
@@ -161,7 +159,7 @@ func TestIptablesManagerDenyRules(t *testing.T) {
|
|||||||
t.Logf(" [%d] %s", i, rule)
|
t.Logf(" [%d] %s", i, rule)
|
||||||
}
|
}
|
||||||
|
|
||||||
var denyRuleIndex, acceptRuleIndex int = -1, -1
|
var denyRuleIndex, acceptRuleIndex = -1, -1
|
||||||
for i, rule := range rules {
|
for i, rule := range rules {
|
||||||
if strings.Contains(rule, "DROP") {
|
if strings.Contains(rule, "DROP") {
|
||||||
t.Logf("Found DROP rule at index %d: %s", i, rule)
|
t.Logf("Found DROP rule at index %d: %s", i, rule)
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import (
|
|||||||
|
|
||||||
"github.com/coreos/go-iptables/iptables"
|
"github.com/coreos/go-iptables/iptables"
|
||||||
"github.com/hashicorp/go-multierror"
|
"github.com/hashicorp/go-multierror"
|
||||||
"github.com/nadoo/ipset"
|
ipset "github.com/lrh3321/ipset-go"
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
|
|
||||||
nberrors "github.com/netbirdio/netbird/client/errors"
|
nberrors "github.com/netbirdio/netbird/client/errors"
|
||||||
@@ -36,6 +36,7 @@ const (
|
|||||||
chainRTFWDOUT = "NETBIRD-RT-FWD-OUT"
|
chainRTFWDOUT = "NETBIRD-RT-FWD-OUT"
|
||||||
chainRTPRE = "NETBIRD-RT-PRE"
|
chainRTPRE = "NETBIRD-RT-PRE"
|
||||||
chainRTRDR = "NETBIRD-RT-RDR"
|
chainRTRDR = "NETBIRD-RT-RDR"
|
||||||
|
chainNATOutput = "NETBIRD-NAT-OUTPUT"
|
||||||
chainRTMSSCLAMP = "NETBIRD-RT-MSSCLAMP"
|
chainRTMSSCLAMP = "NETBIRD-RT-MSSCLAMP"
|
||||||
routingFinalForwardJump = "ACCEPT"
|
routingFinalForwardJump = "ACCEPT"
|
||||||
routingFinalNatJump = "MASQUERADE"
|
routingFinalNatJump = "MASQUERADE"
|
||||||
@@ -43,6 +44,7 @@ const (
|
|||||||
jumpManglePre = "jump-mangle-pre"
|
jumpManglePre = "jump-mangle-pre"
|
||||||
jumpNatPre = "jump-nat-pre"
|
jumpNatPre = "jump-nat-pre"
|
||||||
jumpNatPost = "jump-nat-post"
|
jumpNatPost = "jump-nat-post"
|
||||||
|
jumpNatOutput = "jump-nat-output"
|
||||||
jumpMSSClamp = "jump-mss-clamp"
|
jumpMSSClamp = "jump-mss-clamp"
|
||||||
markManglePre = "mark-mangle-pre"
|
markManglePre = "mark-mangle-pre"
|
||||||
markManglePost = "mark-mangle-post"
|
markManglePost = "mark-mangle-post"
|
||||||
@@ -52,8 +54,10 @@ const (
|
|||||||
snatSuffix = "_snat"
|
snatSuffix = "_snat"
|
||||||
fwdSuffix = "_fwd"
|
fwdSuffix = "_fwd"
|
||||||
|
|
||||||
// ipTCPHeaderMinSize represents minimum IP (20) + TCP (20) header size for MSS calculation
|
// ipv4TCPHeaderSize is the minimum IPv4 (20) + TCP (20) header size for MSS calculation.
|
||||||
ipTCPHeaderMinSize = 40
|
ipv4TCPHeaderSize = 40
|
||||||
|
// ipv6TCPHeaderSize is the minimum IPv6 (40) + TCP (20) header size for MSS calculation.
|
||||||
|
ipv6TCPHeaderSize = 60
|
||||||
)
|
)
|
||||||
|
|
||||||
type ruleInfo struct {
|
type ruleInfo struct {
|
||||||
@@ -84,6 +88,7 @@ type router struct {
|
|||||||
wgIface iFaceMapper
|
wgIface iFaceMapper
|
||||||
legacyManagement bool
|
legacyManagement bool
|
||||||
mtu uint16
|
mtu uint16
|
||||||
|
v6 bool
|
||||||
|
|
||||||
stateManager *statemanager.Manager
|
stateManager *statemanager.Manager
|
||||||
ipFwdState *ipfwdstate.IPForwardingState
|
ipFwdState *ipfwdstate.IPForwardingState
|
||||||
@@ -95,6 +100,7 @@ func newRouter(iptablesClient *iptables.IPTables, wgIface iFaceMapper, mtu uint1
|
|||||||
rules: make(map[string][]string),
|
rules: make(map[string][]string),
|
||||||
wgIface: wgIface,
|
wgIface: wgIface,
|
||||||
mtu: mtu,
|
mtu: mtu,
|
||||||
|
v6: iptablesClient.Proto() == iptables.ProtocolIPv6,
|
||||||
ipFwdState: ipfwdstate.NewIPForwardingState(),
|
ipFwdState: ipfwdstate.NewIPForwardingState(),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -107,10 +113,6 @@ func newRouter(iptablesClient *iptables.IPTables, wgIface iFaceMapper, mtu uint1
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
if err := ipset.Init(); err != nil {
|
|
||||||
return nil, fmt.Errorf("init ipset: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return r, nil
|
return r, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -188,6 +190,11 @@ func (r *router) AddRouteFiltering(
|
|||||||
return ruleKey, nil
|
return ruleKey, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *router) hasRule(id string) bool {
|
||||||
|
_, ok := r.rules[id]
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
func (r *router) DeleteRouteRule(rule firewall.Rule) error {
|
func (r *router) DeleteRouteRule(rule firewall.Rule) error {
|
||||||
ruleKey := rule.ID()
|
ruleKey := rule.ID()
|
||||||
|
|
||||||
@@ -232,12 +239,12 @@ func (r *router) findSets(rule []string) []string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (r *router) createIpSet(setName string, sources []netip.Prefix) error {
|
func (r *router) createIpSet(setName string, sources []netip.Prefix) error {
|
||||||
if err := ipset.Create(setName, ipset.OptTimeout(0)); err != nil {
|
if err := r.createIPSet(setName); err != nil {
|
||||||
return fmt.Errorf("create set %s: %w", setName, err)
|
return fmt.Errorf("create set %s: %w", setName, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, prefix := range sources {
|
for _, prefix := range sources {
|
||||||
if err := ipset.AddPrefix(setName, prefix); err != nil {
|
if err := r.addPrefixToIPSet(setName, prefix); err != nil {
|
||||||
return fmt.Errorf("add element to set %s: %w", setName, err)
|
return fmt.Errorf("add element to set %s: %w", setName, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -246,7 +253,7 @@ func (r *router) createIpSet(setName string, sources []netip.Prefix) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (r *router) deleteIpSet(setName string) error {
|
func (r *router) deleteIpSet(setName string) error {
|
||||||
if err := ipset.Destroy(setName); err != nil {
|
if err := r.destroyIPSet(setName); err != nil {
|
||||||
return fmt.Errorf("destroy set %s: %w", setName, err)
|
return fmt.Errorf("destroy set %s: %w", setName, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -391,6 +398,18 @@ func (r *router) cleanUpDefaultForwardRules() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
log.Debug("flushing routing related tables")
|
log.Debug("flushing routing related tables")
|
||||||
|
|
||||||
|
// Remove jump rules from built-in chains before deleting custom chains,
|
||||||
|
// otherwise the chain deletion fails with "device or resource busy".
|
||||||
|
if ok, err := r.iptablesClient.ChainExists(tableNat, chainNATOutput); err != nil {
|
||||||
|
return fmt.Errorf("check chain %s: %w", chainNATOutput, err)
|
||||||
|
} else if ok {
|
||||||
|
jumpRule := []string{"-j", chainNATOutput}
|
||||||
|
if err := r.iptablesClient.Delete(tableNat, "OUTPUT", jumpRule...); err != nil {
|
||||||
|
log.Debugf("clean OUTPUT jump rule: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for _, chainInfo := range []struct {
|
for _, chainInfo := range []struct {
|
||||||
chain string
|
chain string
|
||||||
table string
|
table string
|
||||||
@@ -400,6 +419,7 @@ func (r *router) cleanUpDefaultForwardRules() error {
|
|||||||
{chainRTPRE, tableMangle},
|
{chainRTPRE, tableMangle},
|
||||||
{chainRTNAT, tableNat},
|
{chainRTNAT, tableNat},
|
||||||
{chainRTRDR, tableNat},
|
{chainRTRDR, tableNat},
|
||||||
|
{chainNATOutput, tableNat},
|
||||||
{chainRTMSSCLAMP, tableMangle},
|
{chainRTMSSCLAMP, tableMangle},
|
||||||
} {
|
} {
|
||||||
ok, err := r.iptablesClient.ChainExists(chainInfo.table, chainInfo.chain)
|
ok, err := r.iptablesClient.ChainExists(chainInfo.table, chainInfo.chain)
|
||||||
@@ -427,6 +447,12 @@ func (r *router) createContainers() error {
|
|||||||
{chainRTRDR, tableNat},
|
{chainRTRDR, tableNat},
|
||||||
{chainRTMSSCLAMP, tableMangle},
|
{chainRTMSSCLAMP, tableMangle},
|
||||||
} {
|
} {
|
||||||
|
// Fallback: clear chains that survived an unclean shutdown.
|
||||||
|
if ok, _ := r.iptablesClient.ChainExists(chainInfo.table, chainInfo.chain); ok {
|
||||||
|
if err := r.iptablesClient.ClearAndDeleteChain(chainInfo.table, chainInfo.chain); err != nil {
|
||||||
|
log.Warnf("clear stale chain %s in %s: %v", chainInfo.chain, chainInfo.table, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
if err := r.iptablesClient.NewChain(chainInfo.table, chainInfo.chain); err != nil {
|
if err := r.iptablesClient.NewChain(chainInfo.table, chainInfo.chain); err != nil {
|
||||||
return fmt.Errorf("create chain %s in table %s: %w", chainInfo.chain, chainInfo.table, err)
|
return fmt.Errorf("create chain %s in table %s: %w", chainInfo.chain, chainInfo.table, err)
|
||||||
}
|
}
|
||||||
@@ -533,9 +559,12 @@ func (r *router) addPostroutingRules() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// addMSSClampingRules adds MSS clamping rules to prevent fragmentation for forwarded traffic.
|
// addMSSClampingRules adds MSS clamping rules to prevent fragmentation for forwarded traffic.
|
||||||
// TODO: Add IPv6 support
|
|
||||||
func (r *router) addMSSClampingRules() error {
|
func (r *router) addMSSClampingRules() error {
|
||||||
mss := r.mtu - ipTCPHeaderMinSize
|
overhead := uint16(ipv4TCPHeaderSize)
|
||||||
|
if r.v6 {
|
||||||
|
overhead = ipv6TCPHeaderSize
|
||||||
|
}
|
||||||
|
mss := r.mtu - overhead
|
||||||
|
|
||||||
// Add jump rule from FORWARD chain in mangle table to our custom chain
|
// Add jump rule from FORWARD chain in mangle table to our custom chain
|
||||||
jumpRule := []string{
|
jumpRule := []string{
|
||||||
@@ -720,8 +749,13 @@ func (r *router) updateState() {
|
|||||||
currentState.Lock()
|
currentState.Lock()
|
||||||
defer currentState.Unlock()
|
defer currentState.Unlock()
|
||||||
|
|
||||||
currentState.RouteRules = r.rules
|
if r.v6 {
|
||||||
currentState.RouteIPsetCounter = r.ipsetCounter
|
currentState.RouteRules6 = r.rules
|
||||||
|
currentState.RouteIPsetCounter6 = r.ipsetCounter
|
||||||
|
} else {
|
||||||
|
currentState.RouteRules = r.rules
|
||||||
|
currentState.RouteIPsetCounter = r.ipsetCounter
|
||||||
|
}
|
||||||
|
|
||||||
if err := r.stateManager.UpdateState(currentState); err != nil {
|
if err := r.stateManager.UpdateState(currentState); err != nil {
|
||||||
log.Errorf("failed to update state: %v", err)
|
log.Errorf("failed to update state: %v", err)
|
||||||
@@ -849,7 +883,7 @@ func (r *router) DeleteDNATRule(rule firewall.Rule) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if fwdRule, exists := r.rules[ruleKey+fwdSuffix]; exists {
|
if fwdRule, exists := r.rules[ruleKey+fwdSuffix]; exists {
|
||||||
if err := r.iptablesClient.Delete(tableFilter, chainRTFWDIN, fwdRule...); err != nil {
|
if err := r.iptablesClient.Delete(tableFilter, chainRTFWDOUT, fwdRule...); err != nil {
|
||||||
merr = multierror.Append(merr, fmt.Errorf("delete forward rule: %w", err))
|
merr = multierror.Append(merr, fmt.Errorf("delete forward rule: %w", err))
|
||||||
}
|
}
|
||||||
delete(r.rules, ruleKey+fwdSuffix)
|
delete(r.rules, ruleKey+fwdSuffix)
|
||||||
@@ -876,7 +910,7 @@ func (r *router) genRouteRuleSpec(params routeFilteringRuleParams, sources []net
|
|||||||
rule = append(rule, destExp...)
|
rule = append(rule, destExp...)
|
||||||
|
|
||||||
if params.Proto != firewall.ProtocolALL {
|
if params.Proto != firewall.ProtocolALL {
|
||||||
rule = append(rule, "-p", strings.ToLower(string(params.Proto)))
|
rule = append(rule, "-p", strings.ToLower(protoForFamily(params.Proto, r.v6)))
|
||||||
rule = append(rule, applyPort("--sport", params.SPort)...)
|
rule = append(rule, applyPort("--sport", params.SPort)...)
|
||||||
rule = append(rule, applyPort("--dport", params.DPort)...)
|
rule = append(rule, applyPort("--dport", params.DPort)...)
|
||||||
}
|
}
|
||||||
@@ -893,11 +927,12 @@ func (r *router) applyNetwork(flag string, network firewall.Network, prefixes []
|
|||||||
}
|
}
|
||||||
|
|
||||||
if network.IsSet() {
|
if network.IsSet() {
|
||||||
if _, err := r.ipsetCounter.Increment(network.Set.HashedName(), prefixes); err != nil {
|
name := r.ipsetName(network.Set.HashedName())
|
||||||
|
if _, err := r.ipsetCounter.Increment(name, prefixes); err != nil {
|
||||||
return nil, fmt.Errorf("create or get ipset: %w", err)
|
return nil, fmt.Errorf("create or get ipset: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return []string{"-m", "set", matchSet, network.Set.HashedName(), direction}, nil
|
return []string{"-m", "set", matchSet, name, direction}, nil
|
||||||
}
|
}
|
||||||
if network.IsPrefix() {
|
if network.IsPrefix() {
|
||||||
return []string{flag, network.Prefix.String()}, nil
|
return []string{flag, network.Prefix.String()}, nil
|
||||||
@@ -908,27 +943,23 @@ func (r *router) applyNetwork(flag string, network firewall.Network, prefixes []
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (r *router) UpdateSet(set firewall.Set, prefixes []netip.Prefix) error {
|
func (r *router) UpdateSet(set firewall.Set, prefixes []netip.Prefix) error {
|
||||||
|
name := r.ipsetName(set.HashedName())
|
||||||
var merr *multierror.Error
|
var merr *multierror.Error
|
||||||
for _, prefix := range prefixes {
|
for _, prefix := range prefixes {
|
||||||
// TODO: Implement IPv6 support
|
if err := r.addPrefixToIPSet(name, prefix); err != nil {
|
||||||
if prefix.Addr().Is6() {
|
merr = multierror.Append(merr, fmt.Errorf("add prefix to ipset: %w", err))
|
||||||
log.Tracef("skipping IPv6 prefix %s: IPv6 support not yet implemented", prefix)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if err := ipset.AddPrefix(set.HashedName(), prefix); err != nil {
|
|
||||||
merr = multierror.Append(merr, fmt.Errorf("increment ipset counter: %w", err))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if merr == nil {
|
if merr == nil {
|
||||||
log.Debugf("updated set %s with prefixes %v", set.HashedName(), prefixes)
|
log.Debugf("updated set %s with prefixes %v", name, prefixes)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nberrors.FormatErrorOrNil(merr)
|
return nberrors.FormatErrorOrNil(merr)
|
||||||
}
|
}
|
||||||
|
|
||||||
// AddInboundDNAT adds an inbound DNAT rule redirecting traffic from NetBird peers to local services.
|
// AddInboundDNAT adds an inbound DNAT rule redirecting traffic from NetBird peers to local services.
|
||||||
func (r *router) AddInboundDNAT(localAddr netip.Addr, protocol firewall.Protocol, sourcePort, targetPort uint16) error {
|
func (r *router) AddInboundDNAT(localAddr netip.Addr, protocol firewall.Protocol, originalPort, translatedPort uint16) error {
|
||||||
ruleID := fmt.Sprintf("inbound-dnat-%s-%s-%d-%d", localAddr.String(), protocol, sourcePort, targetPort)
|
ruleID := fmt.Sprintf("inbound-dnat-%s-%s-%d-%d", localAddr.String(), protocol, originalPort, translatedPort)
|
||||||
|
|
||||||
if _, exists := r.rules[ruleID]; exists {
|
if _, exists := r.rules[ruleID]; exists {
|
||||||
return nil
|
return nil
|
||||||
@@ -936,12 +967,12 @@ func (r *router) AddInboundDNAT(localAddr netip.Addr, protocol firewall.Protocol
|
|||||||
|
|
||||||
dnatRule := []string{
|
dnatRule := []string{
|
||||||
"-i", r.wgIface.Name(),
|
"-i", r.wgIface.Name(),
|
||||||
"-p", strings.ToLower(string(protocol)),
|
"-p", strings.ToLower(protoForFamily(protocol, r.v6)),
|
||||||
"--dport", strconv.Itoa(int(sourcePort)),
|
"--dport", strconv.Itoa(int(originalPort)),
|
||||||
"-d", localAddr.String(),
|
"-d", localAddr.String(),
|
||||||
"-m", "addrtype", "--dst-type", "LOCAL",
|
"-m", "addrtype", "--dst-type", "LOCAL",
|
||||||
"-j", "DNAT",
|
"-j", "DNAT",
|
||||||
"--to-destination", ":" + strconv.Itoa(int(targetPort)),
|
"--to-destination", ":" + strconv.Itoa(int(translatedPort)),
|
||||||
}
|
}
|
||||||
|
|
||||||
ruleInfo := ruleInfo{
|
ruleInfo := ruleInfo{
|
||||||
@@ -960,8 +991,8 @@ func (r *router) AddInboundDNAT(localAddr netip.Addr, protocol firewall.Protocol
|
|||||||
}
|
}
|
||||||
|
|
||||||
// RemoveInboundDNAT removes an inbound DNAT rule.
|
// RemoveInboundDNAT removes an inbound DNAT rule.
|
||||||
func (r *router) RemoveInboundDNAT(localAddr netip.Addr, protocol firewall.Protocol, sourcePort, targetPort uint16) error {
|
func (r *router) RemoveInboundDNAT(localAddr netip.Addr, protocol firewall.Protocol, originalPort, translatedPort uint16) error {
|
||||||
ruleID := fmt.Sprintf("inbound-dnat-%s-%s-%d-%d", localAddr.String(), protocol, sourcePort, targetPort)
|
ruleID := fmt.Sprintf("inbound-dnat-%s-%s-%d-%d", localAddr.String(), protocol, originalPort, translatedPort)
|
||||||
|
|
||||||
if dnatRule, exists := r.rules[ruleID]; exists {
|
if dnatRule, exists := r.rules[ruleID]; exists {
|
||||||
if err := r.iptablesClient.Delete(tableNat, chainRTRDR, dnatRule...); err != nil {
|
if err := r.iptablesClient.Delete(tableNat, chainRTRDR, dnatRule...); err != nil {
|
||||||
@@ -974,6 +1005,81 @@ func (r *router) RemoveInboundDNAT(localAddr netip.Addr, protocol firewall.Proto
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ensureNATOutputChain lazily creates the OUTPUT NAT chain and jump rule on first use.
|
||||||
|
func (r *router) ensureNATOutputChain() error {
|
||||||
|
if _, exists := r.rules[jumpNatOutput]; exists {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
chainExists, err := r.iptablesClient.ChainExists(tableNat, chainNATOutput)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("check chain %s: %w", chainNATOutput, err)
|
||||||
|
}
|
||||||
|
if !chainExists {
|
||||||
|
if err := r.iptablesClient.NewChain(tableNat, chainNATOutput); err != nil {
|
||||||
|
return fmt.Errorf("create chain %s: %w", chainNATOutput, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
jumpRule := []string{"-j", chainNATOutput}
|
||||||
|
if err := r.iptablesClient.Insert(tableNat, "OUTPUT", 1, jumpRule...); err != nil {
|
||||||
|
if !chainExists {
|
||||||
|
if delErr := r.iptablesClient.ClearAndDeleteChain(tableNat, chainNATOutput); delErr != nil {
|
||||||
|
log.Warnf("failed to rollback chain %s: %v", chainNATOutput, delErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fmt.Errorf("add OUTPUT jump rule: %w", err)
|
||||||
|
}
|
||||||
|
r.rules[jumpNatOutput] = jumpRule
|
||||||
|
|
||||||
|
r.updateState()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddOutputDNAT adds an OUTPUT chain DNAT rule for locally-generated traffic.
|
||||||
|
func (r *router) AddOutputDNAT(localAddr netip.Addr, protocol firewall.Protocol, originalPort, translatedPort uint16) error {
|
||||||
|
ruleID := fmt.Sprintf("output-dnat-%s-%s-%d-%d", localAddr.String(), protocol, originalPort, translatedPort)
|
||||||
|
|
||||||
|
if _, exists := r.rules[ruleID]; exists {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := r.ensureNATOutputChain(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
dnatRule := []string{
|
||||||
|
"-p", strings.ToLower(protoForFamily(protocol, localAddr.Is6())),
|
||||||
|
"--dport", strconv.Itoa(int(originalPort)),
|
||||||
|
"-d", localAddr.String(),
|
||||||
|
"-j", "DNAT",
|
||||||
|
"--to-destination", ":" + strconv.Itoa(int(translatedPort)),
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := r.iptablesClient.Append(tableNat, chainNATOutput, dnatRule...); err != nil {
|
||||||
|
return fmt.Errorf("add output DNAT rule: %w", err)
|
||||||
|
}
|
||||||
|
r.rules[ruleID] = dnatRule
|
||||||
|
|
||||||
|
r.updateState()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoveOutputDNAT removes an OUTPUT chain DNAT rule.
|
||||||
|
func (r *router) RemoveOutputDNAT(localAddr netip.Addr, protocol firewall.Protocol, originalPort, translatedPort uint16) error {
|
||||||
|
ruleID := fmt.Sprintf("output-dnat-%s-%s-%d-%d", localAddr.String(), protocol, originalPort, translatedPort)
|
||||||
|
|
||||||
|
if dnatRule, exists := r.rules[ruleID]; exists {
|
||||||
|
if err := r.iptablesClient.Delete(tableNat, chainNATOutput, dnatRule...); err != nil {
|
||||||
|
return fmt.Errorf("delete output DNAT rule: %w", err)
|
||||||
|
}
|
||||||
|
delete(r.rules, ruleID)
|
||||||
|
}
|
||||||
|
|
||||||
|
r.updateState()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func applyPort(flag string, port *firewall.Port) []string {
|
func applyPort(flag string, port *firewall.Port) []string {
|
||||||
if port == nil {
|
if port == nil {
|
||||||
return nil
|
return nil
|
||||||
@@ -993,3 +1099,49 @@ func applyPort(flag string, port *firewall.Port) []string {
|
|||||||
|
|
||||||
return []string{flag, strconv.Itoa(int(port.Values[0]))}
|
return []string{flag, strconv.Itoa(int(port.Values[0]))}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ipsetName returns the ipset name, suffixed with "-v6" for the v6 router
|
||||||
|
// to avoid collisions since ipsets are global in the kernel.
|
||||||
|
func (r *router) ipsetName(name string) string {
|
||||||
|
if r.v6 {
|
||||||
|
return name + "-v6"
|
||||||
|
}
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *router) createIPSet(name string) error {
|
||||||
|
opts := ipset.CreateOptions{
|
||||||
|
Replace: true,
|
||||||
|
}
|
||||||
|
if r.v6 {
|
||||||
|
opts.Family = ipset.FamilyIPV6
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := ipset.Create(name, ipset.TypeHashNet, opts); err != nil {
|
||||||
|
return fmt.Errorf("create ipset %s: %w", name, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Debugf("created ipset %s with type hash:net", name)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *router) addPrefixToIPSet(name string, prefix netip.Prefix) error {
|
||||||
|
addr := prefix.Addr()
|
||||||
|
ip := addr.AsSlice()
|
||||||
|
|
||||||
|
entry := &ipset.Entry{
|
||||||
|
IP: ip,
|
||||||
|
CIDR: uint8(prefix.Bits()),
|
||||||
|
Replace: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := ipset.Add(name, entry); err != nil {
|
||||||
|
return fmt.Errorf("add prefix to ipset %s: %w", name, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *router) destroyIPSet(name string) error {
|
||||||
|
return ipset.Destroy(name)
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ type Rule struct {
|
|||||||
mangleSpecs []string
|
mangleSpecs []string
|
||||||
ip string
|
ip string
|
||||||
chain string
|
chain string
|
||||||
|
v6 bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetRuleID returns the rule id
|
// GetRuleID returns the rule id
|
||||||
|
|||||||
@@ -4,15 +4,16 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
|
|
||||||
"github.com/netbirdio/netbird/client/iface"
|
"github.com/netbirdio/netbird/client/iface"
|
||||||
"github.com/netbirdio/netbird/client/iface/wgaddr"
|
"github.com/netbirdio/netbird/client/iface/wgaddr"
|
||||||
)
|
)
|
||||||
|
|
||||||
type InterfaceState struct {
|
type InterfaceState struct {
|
||||||
NameStr string `json:"name"`
|
NameStr string `json:"name"`
|
||||||
WGAddress wgaddr.Address `json:"wg_address"`
|
WGAddress wgaddr.Address `json:"wg_address"`
|
||||||
UserspaceBind bool `json:"userspace_bind"`
|
MTU uint16 `json:"mtu"`
|
||||||
MTU uint16 `json:"mtu"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (i *InterfaceState) Name() string {
|
func (i *InterfaceState) Name() string {
|
||||||
@@ -23,10 +24,6 @@ func (i *InterfaceState) Address() wgaddr.Address {
|
|||||||
return i.WGAddress
|
return i.WGAddress
|
||||||
}
|
}
|
||||||
|
|
||||||
func (i *InterfaceState) IsUserspaceBind() bool {
|
|
||||||
return i.UserspaceBind
|
|
||||||
}
|
|
||||||
|
|
||||||
type ShutdownState struct {
|
type ShutdownState struct {
|
||||||
sync.Mutex
|
sync.Mutex
|
||||||
|
|
||||||
@@ -37,6 +34,12 @@ type ShutdownState struct {
|
|||||||
|
|
||||||
ACLEntries aclEntries `json:"acl_entries,omitempty"`
|
ACLEntries aclEntries `json:"acl_entries,omitempty"`
|
||||||
ACLIPsetStore *ipsetStore `json:"acl_ipset_store,omitempty"`
|
ACLIPsetStore *ipsetStore `json:"acl_ipset_store,omitempty"`
|
||||||
|
|
||||||
|
// IPv6 counterparts
|
||||||
|
RouteRules6 routeRules `json:"route_rules_v6,omitempty"`
|
||||||
|
RouteIPsetCounter6 *ipsetCounter `json:"route_ipset_counter_v6,omitempty"`
|
||||||
|
ACLEntries6 aclEntries `json:"acl_entries_v6,omitempty"`
|
||||||
|
ACLIPsetStore6 *ipsetStore `json:"acl_ipset_store_v6,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *ShutdownState) Name() string {
|
func (s *ShutdownState) Name() string {
|
||||||
@@ -67,6 +70,28 @@ func (s *ShutdownState) Cleanup() error {
|
|||||||
ipt.aclMgr.ipsetStore = s.ACLIPsetStore
|
ipt.aclMgr.ipsetStore = s.ACLIPsetStore
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Clean up v6 state even if the current run has no IPv6.
|
||||||
|
// The previous run may have left ip6tables rules behind.
|
||||||
|
if !ipt.hasIPv6() {
|
||||||
|
if err := ipt.createIPv6Components(s.InterfaceState, mtu); err != nil {
|
||||||
|
log.Warnf("failed to create v6 components for cleanup: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ipt.hasIPv6() {
|
||||||
|
if s.RouteRules6 != nil {
|
||||||
|
ipt.router6.rules = s.RouteRules6
|
||||||
|
}
|
||||||
|
if s.RouteIPsetCounter6 != nil {
|
||||||
|
ipt.router6.ipsetCounter.LoadData(s.RouteIPsetCounter6)
|
||||||
|
}
|
||||||
|
if s.ACLEntries6 != nil {
|
||||||
|
ipt.aclMgr6.entries = s.ACLEntries6
|
||||||
|
}
|
||||||
|
if s.ACLIPsetStore6 != nil {
|
||||||
|
ipt.aclMgr6.ipsetStore = s.ACLIPsetStore6
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if err := ipt.Close(nil); err != nil {
|
if err := ipt.Close(nil); err != nil {
|
||||||
return fmt.Errorf("reset iptables manager: %w", err)
|
return fmt.Errorf("reset iptables manager: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package manager
|
package manager
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
@@ -11,6 +12,10 @@ import (
|
|||||||
"github.com/netbirdio/netbird/client/internal/statemanager"
|
"github.com/netbirdio/netbird/client/internal/statemanager"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// ErrIPv6NotInitialized is returned when an IPv6 address is passed to a firewall
|
||||||
|
// method but the IPv6 firewall components were not initialized.
|
||||||
|
var ErrIPv6NotInitialized = errors.New("IPv6 firewall not initialized")
|
||||||
|
|
||||||
const (
|
const (
|
||||||
ForwardingFormatPrefix = "netbird-fwd-"
|
ForwardingFormatPrefix = "netbird-fwd-"
|
||||||
ForwardingFormat = "netbird-fwd-%s-%t"
|
ForwardingFormat = "netbird-fwd-%s-%t"
|
||||||
@@ -164,10 +169,20 @@ type Manager interface {
|
|||||||
UpdateSet(hash Set, prefixes []netip.Prefix) error
|
UpdateSet(hash Set, prefixes []netip.Prefix) error
|
||||||
|
|
||||||
// AddInboundDNAT adds an inbound DNAT rule redirecting traffic from NetBird peers to local services
|
// AddInboundDNAT adds an inbound DNAT rule redirecting traffic from NetBird peers to local services
|
||||||
AddInboundDNAT(localAddr netip.Addr, protocol Protocol, sourcePort, targetPort uint16) error
|
AddInboundDNAT(localAddr netip.Addr, protocol Protocol, originalPort, translatedPort uint16) error
|
||||||
|
|
||||||
// RemoveInboundDNAT removes inbound DNAT rule
|
// RemoveInboundDNAT removes inbound DNAT rule
|
||||||
RemoveInboundDNAT(localAddr netip.Addr, protocol Protocol, sourcePort, targetPort uint16) error
|
RemoveInboundDNAT(localAddr netip.Addr, protocol Protocol, originalPort, translatedPort uint16) error
|
||||||
|
|
||||||
|
// AddOutputDNAT adds an OUTPUT chain DNAT rule for locally-generated traffic.
|
||||||
|
AddOutputDNAT(localAddr netip.Addr, protocol Protocol, originalPort, translatedPort uint16) error
|
||||||
|
|
||||||
|
// RemoveOutputDNAT removes an OUTPUT chain DNAT rule.
|
||||||
|
RemoveOutputDNAT(localAddr netip.Addr, protocol Protocol, originalPort, translatedPort uint16) error
|
||||||
|
|
||||||
|
// SetupEBPFProxyNoTrack creates static notrack rules for eBPF proxy loopback traffic.
|
||||||
|
// This prevents conntrack from interfering with WireGuard proxy communication.
|
||||||
|
SetupEBPFProxyNoTrack(proxyPort, wgPort uint16) error
|
||||||
}
|
}
|
||||||
|
|
||||||
func GenKey(format string, pair RouterPair) string {
|
func GenKey(format string, pair RouterPair) string {
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
package manager
|
package manager
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"net/netip"
|
||||||
|
|
||||||
"github.com/netbirdio/netbird/route"
|
"github.com/netbirdio/netbird/route"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -10,6 +12,10 @@ type RouterPair struct {
|
|||||||
Destination Network
|
Destination Network
|
||||||
Masquerade bool
|
Masquerade bool
|
||||||
Inverse bool
|
Inverse bool
|
||||||
|
// Dynamic indicates the route is domain-based. NAT rules for dynamic
|
||||||
|
// routes are duplicated to the v6 table so that resolved AAAA records
|
||||||
|
// are masqueraded correctly.
|
||||||
|
Dynamic bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetInversePair(pair RouterPair) RouterPair {
|
func GetInversePair(pair RouterPair) RouterPair {
|
||||||
@@ -20,5 +26,17 @@ func GetInversePair(pair RouterPair) RouterPair {
|
|||||||
Destination: pair.Source,
|
Destination: pair.Source,
|
||||||
Masquerade: pair.Masquerade,
|
Masquerade: pair.Masquerade,
|
||||||
Inverse: true,
|
Inverse: true,
|
||||||
|
Dynamic: pair.Dynamic,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ToV6NatPair creates a v6 counterpart of a v4 NAT pair with `::/0` source
|
||||||
|
// and, for prefix destinations, `::/0` destination.
|
||||||
|
func ToV6NatPair(pair RouterPair) RouterPair {
|
||||||
|
v6 := pair
|
||||||
|
v6.Source = Network{Prefix: netip.PrefixFrom(netip.IPv6Unspecified(), 0)}
|
||||||
|
if v6.Destination.IsPrefix() {
|
||||||
|
v6.Destination = Network{Prefix: netip.PrefixFrom(netip.IPv6Unspecified(), 0)}
|
||||||
|
}
|
||||||
|
return v6
|
||||||
|
}
|
||||||
|
|||||||
@@ -33,15 +33,12 @@ const (
|
|||||||
|
|
||||||
const flushError = "flush: %w"
|
const flushError = "flush: %w"
|
||||||
|
|
||||||
var (
|
|
||||||
anyIP = []byte{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}
|
|
||||||
)
|
|
||||||
|
|
||||||
type AclManager struct {
|
type AclManager struct {
|
||||||
rConn *nftables.Conn
|
rConn *nftables.Conn
|
||||||
sConn *nftables.Conn
|
sConn *nftables.Conn
|
||||||
wgIface iFaceMapper
|
wgIface iFaceMapper
|
||||||
routingFwChainName string
|
routingFwChainName string
|
||||||
|
af addrFamily
|
||||||
|
|
||||||
workTable *nftables.Table
|
workTable *nftables.Table
|
||||||
chainInputRules *nftables.Chain
|
chainInputRules *nftables.Chain
|
||||||
@@ -67,6 +64,7 @@ func newAclManager(table *nftables.Table, wgIface iFaceMapper, routingFwChainNam
|
|||||||
wgIface: wgIface,
|
wgIface: wgIface,
|
||||||
workTable: table,
|
workTable: table,
|
||||||
routingFwChainName: routingFwChainName,
|
routingFwChainName: routingFwChainName,
|
||||||
|
af: familyForAddr(table.Family == nftables.TableFamilyIPv4),
|
||||||
|
|
||||||
ipsetStore: newIpsetStore(),
|
ipsetStore: newIpsetStore(),
|
||||||
rules: make(map[string]*Rule),
|
rules: make(map[string]*Rule),
|
||||||
@@ -145,7 +143,7 @@ func (m *AclManager) DeletePeerRule(rule firewall.Rule) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if _, ok := ips[r.ip.String()]; ok {
|
if _, ok := ips[r.ip.String()]; ok {
|
||||||
err := m.sConn.SetDeleteElements(r.nftSet, []nftables.SetElement{{Key: r.ip.To4()}})
|
err := m.sConn.SetDeleteElements(r.nftSet, []nftables.SetElement{{Key: ipToBytes(r.ip, m.af)}})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Errorf("delete elements for set %q: %v", r.nftSet.Name, err)
|
log.Errorf("delete elements for set %q: %v", r.nftSet.Name, err)
|
||||||
}
|
}
|
||||||
@@ -254,11 +252,11 @@ func (m *AclManager) addIOFiltering(
|
|||||||
expressions = append(expressions, &expr.Payload{
|
expressions = append(expressions, &expr.Payload{
|
||||||
DestRegister: 1,
|
DestRegister: 1,
|
||||||
Base: expr.PayloadBaseNetworkHeader,
|
Base: expr.PayloadBaseNetworkHeader,
|
||||||
Offset: uint32(9),
|
Offset: m.af.protoOffset,
|
||||||
Len: uint32(1),
|
Len: uint32(1),
|
||||||
})
|
})
|
||||||
|
|
||||||
protoData, err := protoToInt(proto)
|
protoData, err := m.af.protoNum(proto)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("convert protocol to number: %v", err)
|
return nil, fmt.Errorf("convert protocol to number: %v", err)
|
||||||
}
|
}
|
||||||
@@ -270,19 +268,16 @@ func (m *AclManager) addIOFiltering(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
rawIP := ip.To4()
|
rawIP := ipToBytes(ip, m.af)
|
||||||
// check if rawIP contains zeroed IPv4 0.0.0.0 value
|
// check if rawIP contains zeroed IPv4 0.0.0.0 value
|
||||||
// in that case not add IP match expression into the rule definition
|
// in that case not add IP match expression into the rule definition
|
||||||
if !bytes.HasPrefix(anyIP, rawIP) {
|
if slices.ContainsFunc(rawIP, func(v byte) bool { return v != 0 }) {
|
||||||
// source address position
|
|
||||||
addrOffset := uint32(12)
|
|
||||||
|
|
||||||
expressions = append(expressions,
|
expressions = append(expressions,
|
||||||
&expr.Payload{
|
&expr.Payload{
|
||||||
DestRegister: 1,
|
DestRegister: 1,
|
||||||
Base: expr.PayloadBaseNetworkHeader,
|
Base: expr.PayloadBaseNetworkHeader,
|
||||||
Offset: addrOffset,
|
Offset: m.af.srcAddrOffset,
|
||||||
Len: 4,
|
Len: m.af.addrLen,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
// add individual IP for match if no ipset defined
|
// add individual IP for match if no ipset defined
|
||||||
@@ -587,7 +582,7 @@ func (m *AclManager) addJumpRule(chain *nftables.Chain, to string, ifaceKey expr
|
|||||||
|
|
||||||
func (m *AclManager) addIpToSet(ipsetName string, ip net.IP) (*nftables.Set, error) {
|
func (m *AclManager) addIpToSet(ipsetName string, ip net.IP) (*nftables.Set, error) {
|
||||||
ipset, err := m.rConn.GetSetByName(m.workTable, ipsetName)
|
ipset, err := m.rConn.GetSetByName(m.workTable, ipsetName)
|
||||||
rawIP := ip.To4()
|
rawIP := ipToBytes(ip, m.af)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if ipset, err = m.createSet(m.workTable, ipsetName); err != nil {
|
if ipset, err = m.createSet(m.workTable, ipsetName); err != nil {
|
||||||
return nil, fmt.Errorf("get set name: %v", err)
|
return nil, fmt.Errorf("get set name: %v", err)
|
||||||
@@ -619,7 +614,7 @@ func (m *AclManager) createSet(table *nftables.Table, name string) (*nftables.Se
|
|||||||
Name: name,
|
Name: name,
|
||||||
Table: table,
|
Table: table,
|
||||||
Dynamic: true,
|
Dynamic: true,
|
||||||
KeyType: nftables.TypeIPAddr,
|
KeyType: m.af.setKeyType,
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := m.rConn.AddSet(ipset, nil); err != nil {
|
if err := m.rConn.AddSet(ipset, nil); err != nil {
|
||||||
@@ -707,15 +702,12 @@ func ifname(n string) []byte {
|
|||||||
return b
|
return b
|
||||||
}
|
}
|
||||||
|
|
||||||
func protoToInt(protocol firewall.Protocol) (uint8, error) {
|
|
||||||
switch protocol {
|
|
||||||
case firewall.ProtocolTCP:
|
|
||||||
return unix.IPPROTO_TCP, nil
|
|
||||||
case firewall.ProtocolUDP:
|
|
||||||
return unix.IPPROTO_UDP, nil
|
|
||||||
case firewall.ProtocolICMP:
|
|
||||||
return unix.IPPROTO_ICMP, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return 0, fmt.Errorf("unsupported protocol: %s", protocol)
|
// ipToBytes converts net.IP to the correct byte length for the address family.
|
||||||
|
func ipToBytes(ip net.IP, af addrFamily) []byte {
|
||||||
|
if af.addrLen == 4 {
|
||||||
|
return ip.To4()
|
||||||
|
}
|
||||||
|
return ip.To16()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
81
client/firewall/nftables/addr_family_linux.go
Normal file
81
client/firewall/nftables/addr_family_linux.go
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
package nftables
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
|
||||||
|
"github.com/google/nftables"
|
||||||
|
"golang.org/x/sys/unix"
|
||||||
|
|
||||||
|
firewall "github.com/netbirdio/netbird/client/firewall/manager"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
// afIPv4 defines IPv4 header layout and nftables types.
|
||||||
|
afIPv4 = addrFamily{
|
||||||
|
protoOffset: 9,
|
||||||
|
srcAddrOffset: 12,
|
||||||
|
dstAddrOffset: 16,
|
||||||
|
addrLen: net.IPv4len,
|
||||||
|
totalBits: 8 * net.IPv4len,
|
||||||
|
setKeyType: nftables.TypeIPAddr,
|
||||||
|
tableFamily: nftables.TableFamilyIPv4,
|
||||||
|
icmpProto: unix.IPPROTO_ICMP,
|
||||||
|
}
|
||||||
|
// afIPv6 defines IPv6 header layout and nftables types.
|
||||||
|
afIPv6 = addrFamily{
|
||||||
|
protoOffset: 6,
|
||||||
|
srcAddrOffset: 8,
|
||||||
|
dstAddrOffset: 24,
|
||||||
|
addrLen: net.IPv6len,
|
||||||
|
totalBits: 8 * net.IPv6len,
|
||||||
|
setKeyType: nftables.TypeIP6Addr,
|
||||||
|
tableFamily: nftables.TableFamilyIPv6,
|
||||||
|
icmpProto: unix.IPPROTO_ICMPV6,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// addrFamily holds protocol-specific constants for nftables expression building.
|
||||||
|
type addrFamily struct {
|
||||||
|
// protoOffset is the IP header offset for the protocol/next-header field (9 for v4, 6 for v6)
|
||||||
|
protoOffset uint32
|
||||||
|
// srcAddrOffset is the IP header offset for the source address (12 for v4, 8 for v6)
|
||||||
|
srcAddrOffset uint32
|
||||||
|
// dstAddrOffset is the IP header offset for the destination address (16 for v4, 24 for v6)
|
||||||
|
dstAddrOffset uint32
|
||||||
|
// addrLen is the byte length of addresses (4 for v4, 16 for v6)
|
||||||
|
addrLen uint32
|
||||||
|
// totalBits is the address size in bits (32 for v4, 128 for v6)
|
||||||
|
totalBits int
|
||||||
|
// setKeyType is the nftables set data type for addresses
|
||||||
|
setKeyType nftables.SetDatatype
|
||||||
|
// tableFamily is the nftables table family
|
||||||
|
tableFamily nftables.TableFamily
|
||||||
|
// icmpProto is the ICMP protocol number for this family (1 for v4, 58 for v6)
|
||||||
|
icmpProto uint8
|
||||||
|
}
|
||||||
|
|
||||||
|
// familyForAddr returns the address family for the given IP.
|
||||||
|
func familyForAddr(is4 bool) addrFamily {
|
||||||
|
if is4 {
|
||||||
|
return afIPv4
|
||||||
|
}
|
||||||
|
return afIPv6
|
||||||
|
}
|
||||||
|
|
||||||
|
// protoNum converts a firewall protocol to the IP protocol number,
|
||||||
|
// using the correct ICMP variant for the address family.
|
||||||
|
func (af addrFamily) protoNum(protocol firewall.Protocol) (uint8, error) {
|
||||||
|
switch protocol {
|
||||||
|
case firewall.ProtocolTCP:
|
||||||
|
return unix.IPPROTO_TCP, nil
|
||||||
|
case firewall.ProtocolUDP:
|
||||||
|
return unix.IPPROTO_UDP, nil
|
||||||
|
case firewall.ProtocolICMP:
|
||||||
|
return af.icmpProto, nil
|
||||||
|
case firewall.ProtocolALL:
|
||||||
|
return 0, nil
|
||||||
|
default:
|
||||||
|
return 0, fmt.Errorf("unsupported protocol: %s", protocol)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
//go:build linux
|
||||||
|
|
||||||
|
package nftables
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/nftables"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestExternalChainMonitorRootIntegration verifies that adding a new chain
|
||||||
|
// in an external (non-netbird) filter table triggers the reconciler.
|
||||||
|
// Requires CAP_NET_ADMIN; skip otherwise.
|
||||||
|
func TestExternalChainMonitorRootIntegration(t *testing.T) {
|
||||||
|
if os.Geteuid() != 0 {
|
||||||
|
t.Skip("root required")
|
||||||
|
}
|
||||||
|
|
||||||
|
calls := make(chan struct{}, 8)
|
||||||
|
var count atomic.Int32
|
||||||
|
rec := &countingReconciler{calls: calls, count: &count}
|
||||||
|
|
||||||
|
m := newExternalChainMonitor(rec)
|
||||||
|
m.start()
|
||||||
|
t.Cleanup(m.stop)
|
||||||
|
|
||||||
|
// Give the netlink subscription a moment to register.
|
||||||
|
time.Sleep(200 * time.Millisecond)
|
||||||
|
|
||||||
|
conn := &nftables.Conn{}
|
||||||
|
table := conn.AddTable(&nftables.Table{
|
||||||
|
Name: "nbmon_integration_test",
|
||||||
|
Family: nftables.TableFamilyINet,
|
||||||
|
})
|
||||||
|
t.Cleanup(func() {
|
||||||
|
cleanup := &nftables.Conn{}
|
||||||
|
cleanup.DelTable(table)
|
||||||
|
_ = cleanup.Flush()
|
||||||
|
})
|
||||||
|
|
||||||
|
chain := conn.AddChain(&nftables.Chain{
|
||||||
|
Name: "filter_INPUT",
|
||||||
|
Table: table,
|
||||||
|
Hooknum: nftables.ChainHookInput,
|
||||||
|
Priority: nftables.ChainPriorityFilter,
|
||||||
|
Type: nftables.ChainTypeFilter,
|
||||||
|
})
|
||||||
|
_ = chain
|
||||||
|
require.NoError(t, conn.Flush(), "create external test chain")
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-calls:
|
||||||
|
// success
|
||||||
|
case <-time.After(3 * time.Second):
|
||||||
|
t.Fatalf("reconcile was not invoked after creating an external chain")
|
||||||
|
}
|
||||||
|
require.GreaterOrEqual(t, count.Load(), int32(1))
|
||||||
|
}
|
||||||
|
|
||||||
|
type countingReconciler struct {
|
||||||
|
calls chan struct{}
|
||||||
|
count *atomic.Int32
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *countingReconciler) reconcileExternalChains() error {
|
||||||
|
c.count.Add(1)
|
||||||
|
select {
|
||||||
|
case c.calls <- struct{}{}:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
199
client/firewall/nftables/external_chain_monitor_linux.go
Normal file
199
client/firewall/nftables/external_chain_monitor_linux.go
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
package nftables
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/cenkalti/backoff/v4"
|
||||||
|
"github.com/google/nftables"
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
externalMonitorReconcileDelay = 500 * time.Millisecond
|
||||||
|
externalMonitorInitInterval = 5 * time.Second
|
||||||
|
externalMonitorMaxInterval = 5 * time.Minute
|
||||||
|
externalMonitorRandomization = 0.5
|
||||||
|
)
|
||||||
|
|
||||||
|
// externalChainReconciler re-applies passthrough accept rules to external
|
||||||
|
// nftables chains. Implementations must be safe to call from the monitor
|
||||||
|
// goroutine; the Manager locks its mutex internally.
|
||||||
|
type externalChainReconciler interface {
|
||||||
|
reconcileExternalChains() error
|
||||||
|
}
|
||||||
|
|
||||||
|
// externalChainMonitor watches nftables netlink events and triggers a
|
||||||
|
// reconcile when a new table or chain appears (e.g. after
|
||||||
|
// `firewall-cmd --reload`). Netlink errors trigger exponential-backoff
|
||||||
|
// reconnect.
|
||||||
|
type externalChainMonitor struct {
|
||||||
|
reconciler externalChainReconciler
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
cancel context.CancelFunc
|
||||||
|
done chan struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newExternalChainMonitor(r externalChainReconciler) *externalChainMonitor {
|
||||||
|
return &externalChainMonitor{reconciler: r}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *externalChainMonitor) start() {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
|
||||||
|
if m.cancel != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
m.cancel = cancel
|
||||||
|
m.done = make(chan struct{})
|
||||||
|
|
||||||
|
go m.run(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *externalChainMonitor) stop() {
|
||||||
|
m.mu.Lock()
|
||||||
|
cancel := m.cancel
|
||||||
|
done := m.done
|
||||||
|
m.cancel = nil
|
||||||
|
m.done = nil
|
||||||
|
m.mu.Unlock()
|
||||||
|
|
||||||
|
if cancel == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cancel()
|
||||||
|
<-done
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *externalChainMonitor) run(ctx context.Context) {
|
||||||
|
defer close(m.done)
|
||||||
|
|
||||||
|
bo := &backoff.ExponentialBackOff{
|
||||||
|
InitialInterval: externalMonitorInitInterval,
|
||||||
|
RandomizationFactor: externalMonitorRandomization,
|
||||||
|
Multiplier: backoff.DefaultMultiplier,
|
||||||
|
MaxInterval: externalMonitorMaxInterval,
|
||||||
|
MaxElapsedTime: 0,
|
||||||
|
Clock: backoff.SystemClock,
|
||||||
|
}
|
||||||
|
bo.Reset()
|
||||||
|
|
||||||
|
for ctx.Err() == nil {
|
||||||
|
err := m.watch(ctx)
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
delay := bo.NextBackOff()
|
||||||
|
log.Warnf("external chain monitor: %v, reconnecting in %s", err, delay)
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-time.After(delay):
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *externalChainMonitor) watch(ctx context.Context) error {
|
||||||
|
events, closeMon, err := m.subscribe()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer closeMon()
|
||||||
|
|
||||||
|
debounce := time.NewTimer(time.Hour)
|
||||||
|
if !debounce.Stop() {
|
||||||
|
<-debounce.C
|
||||||
|
}
|
||||||
|
defer debounce.Stop()
|
||||||
|
|
||||||
|
pending := false
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return nil
|
||||||
|
case <-debounce.C:
|
||||||
|
pending = false
|
||||||
|
m.reconcile()
|
||||||
|
case ev, ok := <-events:
|
||||||
|
if !ok {
|
||||||
|
return errors.New("monitor channel closed")
|
||||||
|
}
|
||||||
|
if ev.Error != nil {
|
||||||
|
return fmt.Errorf("monitor event: %w", ev.Error)
|
||||||
|
}
|
||||||
|
if !isRelevantMonitorEvent(ev) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
resetDebounce(debounce, pending)
|
||||||
|
pending = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *externalChainMonitor) subscribe() (chan *nftables.MonitorEvent, func(), error) {
|
||||||
|
conn := &nftables.Conn{}
|
||||||
|
mon := nftables.NewMonitor(
|
||||||
|
nftables.WithMonitorAction(nftables.MonitorActionNew),
|
||||||
|
nftables.WithMonitorObject(nftables.MonitorObjectChains|nftables.MonitorObjectTables),
|
||||||
|
)
|
||||||
|
events, err := conn.AddMonitor(mon)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("add netlink monitor: %w", err)
|
||||||
|
}
|
||||||
|
return events, func() { _ = mon.Close() }, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// resetDebounce reschedules a pending debounce timer without leaking a stale
|
||||||
|
// fire on its channel. pending must reflect whether the timer is armed.
|
||||||
|
func resetDebounce(t *time.Timer, pending bool) {
|
||||||
|
if pending && !t.Stop() {
|
||||||
|
select {
|
||||||
|
case <-t.C:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Reset(externalMonitorReconcileDelay)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *externalChainMonitor) reconcile() {
|
||||||
|
if err := m.reconciler.reconcileExternalChains(); err != nil {
|
||||||
|
log.Warnf("reconcile external chain rules: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// isRelevantMonitorEvent returns true for table/chain creation events on
|
||||||
|
// families we care about. The reconciler filters to actual external filter
|
||||||
|
// chains.
|
||||||
|
func isRelevantMonitorEvent(ev *nftables.MonitorEvent) bool {
|
||||||
|
switch ev.Type {
|
||||||
|
case nftables.MonitorEventTypeNewChain:
|
||||||
|
chain, ok := ev.Data.(*nftables.Chain)
|
||||||
|
if !ok || chain == nil || chain.Table == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return isMonitoredFamily(chain.Table.Family)
|
||||||
|
case nftables.MonitorEventTypeNewTable:
|
||||||
|
table, ok := ev.Data.(*nftables.Table)
|
||||||
|
if !ok || table == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return isMonitoredFamily(table.Family)
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func isMonitoredFamily(family nftables.TableFamily) bool {
|
||||||
|
switch family {
|
||||||
|
case nftables.TableFamilyIPv4, nftables.TableFamilyIPv6, nftables.TableFamilyINet:
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
137
client/firewall/nftables/external_chain_monitor_linux_test.go
Normal file
137
client/firewall/nftables/external_chain_monitor_linux_test.go
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
package nftables
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/google/nftables"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestIsMonitoredFamily(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
family nftables.TableFamily
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{nftables.TableFamilyIPv4, true},
|
||||||
|
{nftables.TableFamilyIPv6, true},
|
||||||
|
{nftables.TableFamilyINet, true},
|
||||||
|
{nftables.TableFamilyARP, false},
|
||||||
|
{nftables.TableFamilyBridge, false},
|
||||||
|
{nftables.TableFamilyNetdev, false},
|
||||||
|
{nftables.TableFamilyUnspecified, false},
|
||||||
|
}
|
||||||
|
for _, tc := range tests {
|
||||||
|
assert.Equal(t, tc.want, isMonitoredFamily(tc.family), "family=%d", tc.family)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsRelevantMonitorEvent(t *testing.T) {
|
||||||
|
inetTable := &nftables.Table{Name: "firewalld", Family: nftables.TableFamilyINet}
|
||||||
|
ipTable := &nftables.Table{Name: "filter", Family: nftables.TableFamilyIPv4}
|
||||||
|
arpTable := &nftables.Table{Name: "arp", Family: nftables.TableFamilyARP}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
ev *nftables.MonitorEvent
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "new chain in inet firewalld",
|
||||||
|
ev: &nftables.MonitorEvent{
|
||||||
|
Type: nftables.MonitorEventTypeNewChain,
|
||||||
|
Data: &nftables.Chain{Name: "filter_INPUT", Table: inetTable},
|
||||||
|
},
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "new chain in ip filter",
|
||||||
|
ev: &nftables.MonitorEvent{
|
||||||
|
Type: nftables.MonitorEventTypeNewChain,
|
||||||
|
Data: &nftables.Chain{Name: "INPUT", Table: ipTable},
|
||||||
|
},
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "new chain in unwatched arp family",
|
||||||
|
ev: &nftables.MonitorEvent{
|
||||||
|
Type: nftables.MonitorEventTypeNewChain,
|
||||||
|
Data: &nftables.Chain{Name: "x", Table: arpTable},
|
||||||
|
},
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "new table inet",
|
||||||
|
ev: &nftables.MonitorEvent{
|
||||||
|
Type: nftables.MonitorEventTypeNewTable,
|
||||||
|
Data: inetTable,
|
||||||
|
},
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "del chain (we only act on new)",
|
||||||
|
ev: &nftables.MonitorEvent{
|
||||||
|
Type: nftables.MonitorEventTypeDelChain,
|
||||||
|
Data: &nftables.Chain{Name: "filter_INPUT", Table: inetTable},
|
||||||
|
},
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "chain with nil table",
|
||||||
|
ev: &nftables.MonitorEvent{
|
||||||
|
Type: nftables.MonitorEventTypeNewChain,
|
||||||
|
Data: &nftables.Chain{Name: "x"},
|
||||||
|
},
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "nil data",
|
||||||
|
ev: &nftables.MonitorEvent{
|
||||||
|
Type: nftables.MonitorEventTypeNewChain,
|
||||||
|
Data: (*nftables.Chain)(nil),
|
||||||
|
},
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
assert.Equal(t, tc.want, isRelevantMonitorEvent(tc.ev))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// fakeReconciler records reconcile invocations for debounce tests.
|
||||||
|
type fakeReconciler struct {
|
||||||
|
calls chan struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeReconciler) reconcileExternalChains() error {
|
||||||
|
f.calls <- struct{}{}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExternalChainMonitorStopWithoutStart(t *testing.T) {
|
||||||
|
m := newExternalChainMonitor(&fakeReconciler{calls: make(chan struct{}, 1)})
|
||||||
|
// Must not panic or block.
|
||||||
|
m.stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExternalChainMonitorDoubleStart(t *testing.T) {
|
||||||
|
// start() twice should be a no-op; stop() cleans up once.
|
||||||
|
// We avoid exercising the netlink watch loop here because it needs root.
|
||||||
|
m := newExternalChainMonitor(&fakeReconciler{calls: make(chan struct{}, 1)})
|
||||||
|
|
||||||
|
// Replace run with a stub that just waits for cancel, so start() stays
|
||||||
|
// deterministic without opening a netlink socket.
|
||||||
|
origDone := make(chan struct{})
|
||||||
|
m.done = origDone
|
||||||
|
m.cancel = func() { close(origDone) }
|
||||||
|
|
||||||
|
// Second start should be a no-op (cancel already set).
|
||||||
|
m.start()
|
||||||
|
assert.NotNil(t, m.cancel)
|
||||||
|
|
||||||
|
m.stop()
|
||||||
|
assert.Nil(t, m.cancel)
|
||||||
|
assert.Nil(t, m.done)
|
||||||
|
}
|
||||||
@@ -11,8 +11,12 @@ import (
|
|||||||
"github.com/google/nftables"
|
"github.com/google/nftables"
|
||||||
"github.com/google/nftables/binaryutil"
|
"github.com/google/nftables/binaryutil"
|
||||||
"github.com/google/nftables/expr"
|
"github.com/google/nftables/expr"
|
||||||
|
"github.com/hashicorp/go-multierror"
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
|
"golang.org/x/sys/unix"
|
||||||
|
|
||||||
|
nberrors "github.com/netbirdio/netbird/client/errors"
|
||||||
|
"github.com/netbirdio/netbird/client/firewall/firewalld"
|
||||||
firewall "github.com/netbirdio/netbird/client/firewall/manager"
|
firewall "github.com/netbirdio/netbird/client/firewall/manager"
|
||||||
"github.com/netbirdio/netbird/client/iface/wgaddr"
|
"github.com/netbirdio/netbird/client/iface/wgaddr"
|
||||||
"github.com/netbirdio/netbird/client/internal/statemanager"
|
"github.com/netbirdio/netbird/client/internal/statemanager"
|
||||||
@@ -39,7 +43,6 @@ func getTableName() string {
|
|||||||
type iFaceMapper interface {
|
type iFaceMapper interface {
|
||||||
Name() string
|
Name() string
|
||||||
Address() wgaddr.Address
|
Address() wgaddr.Address
|
||||||
IsUserspaceBind() bool
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Manager of iptables firewall
|
// Manager of iptables firewall
|
||||||
@@ -50,6 +53,15 @@ type Manager struct {
|
|||||||
|
|
||||||
router *router
|
router *router
|
||||||
aclManager *AclManager
|
aclManager *AclManager
|
||||||
|
|
||||||
|
// IPv6 counterparts, nil when no v6 overlay
|
||||||
|
router6 *router
|
||||||
|
aclManager6 *AclManager
|
||||||
|
|
||||||
|
notrackOutputChain *nftables.Chain
|
||||||
|
notrackPreroutingChain *nftables.Chain
|
||||||
|
|
||||||
|
extMonitor *externalChainMonitor
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create nftables firewall manager
|
// Create nftables firewall manager
|
||||||
@@ -59,7 +71,8 @@ func Create(wgIface iFaceMapper, mtu uint16) (*Manager, error) {
|
|||||||
wgIface: wgIface,
|
wgIface: wgIface,
|
||||||
}
|
}
|
||||||
|
|
||||||
workTable := &nftables.Table{Name: getTableName(), Family: nftables.TableFamilyIPv4}
|
tableName := getTableName()
|
||||||
|
workTable := &nftables.Table{Name: tableName, Family: nftables.TableFamilyIPv4}
|
||||||
|
|
||||||
var err error
|
var err error
|
||||||
m.router, err = newRouter(workTable, wgIface, mtu)
|
m.router, err = newRouter(workTable, wgIface, mtu)
|
||||||
@@ -72,50 +85,170 @@ func Create(wgIface iFaceMapper, mtu uint16) (*Manager, error) {
|
|||||||
return nil, fmt.Errorf("create acl manager: %w", err)
|
return nil, fmt.Errorf("create acl manager: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if wgIface.Address().HasIPv6() {
|
||||||
|
if err := m.createIPv6Components(tableName, wgIface, mtu); err != nil {
|
||||||
|
return nil, fmt.Errorf("create IPv6 firewall: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
m.extMonitor = newExternalChainMonitor(m)
|
||||||
|
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *Manager) createIPv6Components(tableName string, wgIface iFaceMapper, mtu uint16) error {
|
||||||
|
workTable6 := &nftables.Table{Name: tableName, Family: nftables.TableFamilyIPv6}
|
||||||
|
|
||||||
|
var err error
|
||||||
|
m.router6, err = newRouter(workTable6, wgIface, mtu)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("create v6 router: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Share the same IP forwarding state with the v4 router, since
|
||||||
|
// EnableIPForwarding controls both v4 and v6 sysctls.
|
||||||
|
m.router6.ipFwdState = m.router.ipFwdState
|
||||||
|
|
||||||
|
m.aclManager6, err = newAclManager(workTable6, wgIface, chainNameRoutingFw)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("create v6 acl manager: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// hasIPv6 reports whether the manager has IPv6 components initialized.
|
||||||
|
func (m *Manager) hasIPv6() bool {
|
||||||
|
return m.router6 != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) initIPv6() error {
|
||||||
|
workTable6, err := m.createWorkTableFamily(nftables.TableFamilyIPv6)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("create v6 work table: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := m.router6.init(workTable6); err != nil {
|
||||||
|
return fmt.Errorf("v6 router init: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := m.aclManager6.init(workTable6); err != nil {
|
||||||
|
return fmt.Errorf("v6 acl manager init: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// Init nftables firewall manager
|
// Init nftables firewall manager
|
||||||
func (m *Manager) Init(stateManager *statemanager.Manager) error {
|
func (m *Manager) Init(stateManager *statemanager.Manager) error {
|
||||||
|
if err := m.initFirewall(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
m.persistState(stateManager)
|
||||||
|
|
||||||
|
// Start after initFirewall has installed the baseline external-chain
|
||||||
|
// accept rules. start() is idempotent across Init/Close/Init cycles.
|
||||||
|
m.extMonitor.start()
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// reconcileExternalChains re-applies passthrough accept rules to external
|
||||||
|
// filter chains for both IPv4 and IPv6 routers. Called by the monitor when
|
||||||
|
// tables or chains appear (e.g. after firewalld reloads).
|
||||||
|
func (m *Manager) reconcileExternalChains() error {
|
||||||
|
m.mutex.Lock()
|
||||||
|
defer m.mutex.Unlock()
|
||||||
|
|
||||||
|
var merr *multierror.Error
|
||||||
|
if m.router != nil {
|
||||||
|
if err := m.router.acceptExternalChainsRules(); err != nil {
|
||||||
|
merr = multierror.Append(merr, fmt.Errorf("v4: %w", err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if m.hasIPv6() {
|
||||||
|
if err := m.router6.acceptExternalChainsRules(); err != nil {
|
||||||
|
merr = multierror.Append(merr, fmt.Errorf("v6: %w", err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nberrors.FormatErrorOrNil(merr)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) initFirewall() (err error) {
|
||||||
workTable, err := m.createWorkTable()
|
workTable, err := m.createWorkTable()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("create work table: %w", err)
|
return fmt.Errorf("create work table: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
defer func() {
|
||||||
|
if err != nil {
|
||||||
|
m.rollbackInit()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
if err := m.router.init(workTable); err != nil {
|
if err := m.router.init(workTable); err != nil {
|
||||||
return fmt.Errorf("router init: %w", err)
|
return fmt.Errorf("router init: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := m.aclManager.init(workTable); err != nil {
|
if err := m.aclManager.init(workTable); err != nil {
|
||||||
// TODO: cleanup router
|
|
||||||
return fmt.Errorf("acl manager init: %w", err)
|
return fmt.Errorf("acl manager init: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if m.hasIPv6() {
|
||||||
|
if err := m.initIPv6(); err != nil {
|
||||||
|
// Peer has a v6 address: v6 firewall MUST work or we risk fail-open.
|
||||||
|
return fmt.Errorf("init IPv6 firewall (required because peer has IPv6 address): %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := m.initNoTrackChains(workTable); err != nil {
|
||||||
|
log.Warnf("raw priority chains not available, notrack rules will be disabled: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// persistState saves the current interface state for potential recreation on restart.
|
||||||
|
// Unlike iptables, which requires tracking individual rules, nftables maintains
|
||||||
|
// a known state (our netbird table plus a few static rules). This allows for easy
|
||||||
|
// cleanup using Close() without needing to store specific rules.
|
||||||
|
func (m *Manager) persistState(stateManager *statemanager.Manager) {
|
||||||
stateManager.RegisterState(&ShutdownState{})
|
stateManager.RegisterState(&ShutdownState{})
|
||||||
|
|
||||||
// We only need to record minimal interface state for potential recreation.
|
|
||||||
// Unlike iptables, which requires tracking individual rules, nftables maintains
|
|
||||||
// a known state (our netbird table plus a few static rules). This allows for easy
|
|
||||||
// cleanup using Close() without needing to store specific rules.
|
|
||||||
if err := stateManager.UpdateState(&ShutdownState{
|
if err := stateManager.UpdateState(&ShutdownState{
|
||||||
InterfaceState: &InterfaceState{
|
InterfaceState: &InterfaceState{
|
||||||
NameStr: m.wgIface.Name(),
|
NameStr: m.wgIface.Name(),
|
||||||
WGAddress: m.wgIface.Address(),
|
WGAddress: m.wgIface.Address(),
|
||||||
UserspaceBind: m.wgIface.IsUserspaceBind(),
|
MTU: m.router.mtu,
|
||||||
MTU: m.router.mtu,
|
|
||||||
},
|
},
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
log.Errorf("failed to update state: %v", err)
|
log.Errorf("failed to update state: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// persist early
|
|
||||||
go func() {
|
go func() {
|
||||||
if err := stateManager.PersistState(context.Background()); err != nil {
|
if err := stateManager.PersistState(context.Background()); err != nil {
|
||||||
log.Errorf("failed to persist state: %v", err)
|
log.Errorf("failed to persist state: %v", err)
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
// rollbackInit performs best-effort cleanup of already-initialized state when Init fails partway through.
|
||||||
|
func (m *Manager) rollbackInit() {
|
||||||
|
if err := m.router.Reset(); err != nil {
|
||||||
|
log.Warnf("rollback router: %v", err)
|
||||||
|
}
|
||||||
|
if m.hasIPv6() {
|
||||||
|
if err := m.router6.Reset(); err != nil {
|
||||||
|
log.Warnf("rollback v6 router: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := m.cleanupNetbirdTables(); err != nil {
|
||||||
|
log.Warnf("cleanup tables: %v", err)
|
||||||
|
}
|
||||||
|
if err := m.rConn.Flush(); err != nil {
|
||||||
|
log.Warnf("flush: %v", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// AddPeerFiltering rule to the firewall
|
// AddPeerFiltering rule to the firewall
|
||||||
@@ -134,12 +267,14 @@ func (m *Manager) AddPeerFiltering(
|
|||||||
m.mutex.Lock()
|
m.mutex.Lock()
|
||||||
defer m.mutex.Unlock()
|
defer m.mutex.Unlock()
|
||||||
|
|
||||||
rawIP := ip.To4()
|
if ip.To4() != nil {
|
||||||
if rawIP == nil {
|
return m.aclManager.AddPeerFiltering(id, ip, proto, sPort, dPort, action, ipsetName)
|
||||||
return nil, fmt.Errorf("unsupported IP version: %s", ip.String())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return m.aclManager.AddPeerFiltering(id, ip, proto, sPort, dPort, action, ipsetName)
|
if !m.hasIPv6() {
|
||||||
|
return nil, fmt.Errorf("add peer filtering for %s: %w", ip, firewall.ErrIPv6NotInitialized)
|
||||||
|
}
|
||||||
|
return m.aclManager6.AddPeerFiltering(id, ip, proto, sPort, dPort, action, ipsetName)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) AddRouteFiltering(
|
func (m *Manager) AddRouteFiltering(
|
||||||
@@ -153,8 +288,11 @@ func (m *Manager) AddRouteFiltering(
|
|||||||
m.mutex.Lock()
|
m.mutex.Lock()
|
||||||
defer m.mutex.Unlock()
|
defer m.mutex.Unlock()
|
||||||
|
|
||||||
if destination.IsPrefix() && !destination.Prefix.Addr().Is4() {
|
if isIPv6RouteRule(sources, destination) {
|
||||||
return nil, fmt.Errorf("unsupported IP version: %s", destination.Prefix.Addr().String())
|
if !m.hasIPv6() {
|
||||||
|
return nil, fmt.Errorf("add route filtering: %w", firewall.ErrIPv6NotInitialized)
|
||||||
|
}
|
||||||
|
return m.router6.AddRouteFiltering(id, sources, destination, proto, sPort, dPort, action)
|
||||||
}
|
}
|
||||||
|
|
||||||
return m.router.AddRouteFiltering(id, sources, destination, proto, sPort, dPort, action)
|
return m.router.AddRouteFiltering(id, sources, destination, proto, sPort, dPort, action)
|
||||||
@@ -165,15 +303,66 @@ func (m *Manager) DeletePeerRule(rule firewall.Rule) error {
|
|||||||
m.mutex.Lock()
|
m.mutex.Lock()
|
||||||
defer m.mutex.Unlock()
|
defer m.mutex.Unlock()
|
||||||
|
|
||||||
|
if m.hasIPv6() && isIPv6Rule(rule) {
|
||||||
|
return m.aclManager6.DeletePeerRule(rule)
|
||||||
|
}
|
||||||
return m.aclManager.DeletePeerRule(rule)
|
return m.aclManager.DeletePeerRule(rule)
|
||||||
}
|
}
|
||||||
|
|
||||||
// DeleteRouteRule deletes a routing rule
|
func isIPv6Rule(rule firewall.Rule) bool {
|
||||||
|
r, ok := rule.(*Rule)
|
||||||
|
return ok && r.nftRule != nil && r.nftRule.Table != nil && r.nftRule.Table.Family == nftables.TableFamilyIPv6
|
||||||
|
}
|
||||||
|
|
||||||
|
// isIPv6RouteRule determines whether a route rule belongs to the v6 table.
|
||||||
|
// For static routes, the destination prefix determines the family. For dynamic
|
||||||
|
// routes (DomainSet), the sources determine the family since management
|
||||||
|
// duplicates dynamic rules per family.
|
||||||
|
func isIPv6RouteRule(sources []netip.Prefix, destination firewall.Network) bool {
|
||||||
|
if destination.IsPrefix() {
|
||||||
|
return destination.Prefix.Addr().Is6()
|
||||||
|
}
|
||||||
|
return len(sources) > 0 && sources[0].Addr().Is6()
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteRouteRule deletes a routing rule. Route rules live in exactly one
|
||||||
|
// router; the cached maps are normally authoritative, so the kernel is only
|
||||||
|
// consulted when neither map knows about the rule.
|
||||||
func (m *Manager) DeleteRouteRule(rule firewall.Rule) error {
|
func (m *Manager) DeleteRouteRule(rule firewall.Rule) error {
|
||||||
m.mutex.Lock()
|
m.mutex.Lock()
|
||||||
defer m.mutex.Unlock()
|
defer m.mutex.Unlock()
|
||||||
|
|
||||||
return m.router.DeleteRouteRule(rule)
|
id := rule.ID()
|
||||||
|
r, err := m.routerForRuleID(id, (*router).hasRule)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return r.DeleteRouteRule(rule)
|
||||||
|
}
|
||||||
|
|
||||||
|
// routerForRuleID picks the router holding the rule with the given id, using
|
||||||
|
// the supplied lookup. If the cached maps disagree (or both miss), it refreshes
|
||||||
|
// from the kernel once and re-checks before falling back to the v4 router.
|
||||||
|
func (m *Manager) routerForRuleID(id string, has func(*router, string) bool) (*router, error) {
|
||||||
|
if has(m.router, id) {
|
||||||
|
return m.router, nil
|
||||||
|
}
|
||||||
|
if m.hasIPv6() && has(m.router6, id) {
|
||||||
|
return m.router6, nil
|
||||||
|
}
|
||||||
|
if !m.hasIPv6() {
|
||||||
|
return m.router, nil
|
||||||
|
}
|
||||||
|
if err := m.router.refreshRulesMap(); err != nil {
|
||||||
|
return nil, fmt.Errorf("refresh v4 rules: %w", err)
|
||||||
|
}
|
||||||
|
if err := m.router6.refreshRulesMap(); err != nil {
|
||||||
|
return nil, fmt.Errorf("refresh v6 rules: %w", err)
|
||||||
|
}
|
||||||
|
if has(m.router6, id) && !has(m.router, id) {
|
||||||
|
return m.router6, nil
|
||||||
|
}
|
||||||
|
return m.router, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) IsServerRouteSupported() bool {
|
func (m *Manager) IsServerRouteSupported() bool {
|
||||||
@@ -188,62 +377,136 @@ func (m *Manager) AddNatRule(pair firewall.RouterPair) error {
|
|||||||
m.mutex.Lock()
|
m.mutex.Lock()
|
||||||
defer m.mutex.Unlock()
|
defer m.mutex.Unlock()
|
||||||
|
|
||||||
return m.router.AddNatRule(pair)
|
if pair.Destination.IsPrefix() && pair.Destination.Prefix.Addr().Is6() {
|
||||||
|
if !m.hasIPv6() {
|
||||||
|
return fmt.Errorf("add NAT rule: %w", firewall.ErrIPv6NotInitialized)
|
||||||
|
}
|
||||||
|
return m.router6.AddNatRule(pair)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := m.router.AddNatRule(pair); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dynamic routes need NAT in both tables since resolved IPs can be
|
||||||
|
// either v4 or v6. This covers both DomainSet (modern) and the legacy
|
||||||
|
// wildcard 0.0.0.0/0 destination where the client resolves DNS.
|
||||||
|
// On v6 failure we keep the v4 NAT rule rather than rolling back: half
|
||||||
|
// connectivity is better than none, and RemoveNatRule is content-keyed
|
||||||
|
// so the eventual cleanup still works.
|
||||||
|
if m.hasIPv6() && pair.Dynamic {
|
||||||
|
v6Pair := firewall.ToV6NatPair(pair)
|
||||||
|
if err := m.router6.AddNatRule(v6Pair); err != nil {
|
||||||
|
return fmt.Errorf("add v6 NAT rule: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) RemoveNatRule(pair firewall.RouterPair) error {
|
func (m *Manager) RemoveNatRule(pair firewall.RouterPair) error {
|
||||||
m.mutex.Lock()
|
m.mutex.Lock()
|
||||||
defer m.mutex.Unlock()
|
defer m.mutex.Unlock()
|
||||||
|
|
||||||
return m.router.RemoveNatRule(pair)
|
if pair.Destination.IsPrefix() && pair.Destination.Prefix.Addr().Is6() {
|
||||||
}
|
if !m.hasIPv6() {
|
||||||
|
return nil
|
||||||
// AllowNetbird allows netbird interface traffic
|
}
|
||||||
func (m *Manager) AllowNetbird() error {
|
return m.router6.RemoveNatRule(pair)
|
||||||
if !m.wgIface.IsUserspaceBind() {
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var merr *multierror.Error
|
||||||
|
|
||||||
|
if err := m.router.RemoveNatRule(pair); err != nil {
|
||||||
|
merr = multierror.Append(merr, fmt.Errorf("remove v4 NAT rule: %w", err))
|
||||||
|
}
|
||||||
|
|
||||||
|
if m.hasIPv6() && pair.Dynamic {
|
||||||
|
v6Pair := firewall.ToV6NatPair(pair)
|
||||||
|
if err := m.router6.RemoveNatRule(v6Pair); err != nil {
|
||||||
|
merr = multierror.Append(merr, fmt.Errorf("remove v6 NAT rule: %w", err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nberrors.FormatErrorOrNil(merr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AllowNetbird allows netbird interface traffic.
|
||||||
|
// This is called when USPFilter wraps the native firewall, adding blanket accept
|
||||||
|
// rules so that packet filtering is handled in userspace instead of by netfilter.
|
||||||
|
//
|
||||||
|
// TODO: In USP mode this only adds ACCEPT to the netbird table's own chains,
|
||||||
|
// which doesn't override DROP rules in external tables (e.g. firewalld).
|
||||||
|
// Should add passthrough rules to external chains (like the native mode router's
|
||||||
|
// addExternalChainsRules does) for both the netbird table family and inet tables.
|
||||||
|
// The netbird table itself is fine (routing chains already exist there), but
|
||||||
|
// non-netbird tables with INPUT/FORWARD hooks can still DROP our WG traffic.
|
||||||
|
func (m *Manager) AllowNetbird() error {
|
||||||
m.mutex.Lock()
|
m.mutex.Lock()
|
||||||
defer m.mutex.Unlock()
|
defer m.mutex.Unlock()
|
||||||
|
|
||||||
if err := m.aclManager.createDefaultAllowRules(); err != nil {
|
if err := m.aclManager.createDefaultAllowRules(); err != nil {
|
||||||
return fmt.Errorf("create default allow rules: %w", err)
|
return fmt.Errorf("create default allow rules: %w", err)
|
||||||
}
|
}
|
||||||
|
if m.hasIPv6() {
|
||||||
|
if err := m.aclManager6.createDefaultAllowRules(); err != nil {
|
||||||
|
return fmt.Errorf("create v6 default allow rules: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
if err := m.rConn.Flush(); err != nil {
|
if err := m.rConn.Flush(); err != nil {
|
||||||
return fmt.Errorf("flush allow input netbird rules: %w", err)
|
return fmt.Errorf("flush allow input netbird rules: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err := firewalld.TrustInterface(m.wgIface.Name()); err != nil {
|
||||||
|
log.Warnf("failed to trust interface in firewalld: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetLegacyManagement sets the route manager to use legacy management
|
// SetLegacyManagement sets the route manager to use legacy management
|
||||||
func (m *Manager) SetLegacyManagement(isLegacy bool) error {
|
func (m *Manager) SetLegacyManagement(isLegacy bool) error {
|
||||||
return firewall.SetLegacyManagement(m.router, isLegacy)
|
if err := firewall.SetLegacyManagement(m.router, isLegacy); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if m.hasIPv6() {
|
||||||
|
return firewall.SetLegacyManagement(m.router6, isLegacy)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Close closes the firewall manager
|
// Close closes the firewall manager
|
||||||
func (m *Manager) Close(stateManager *statemanager.Manager) error {
|
func (m *Manager) Close(stateManager *statemanager.Manager) error {
|
||||||
|
m.extMonitor.stop()
|
||||||
|
|
||||||
m.mutex.Lock()
|
m.mutex.Lock()
|
||||||
defer m.mutex.Unlock()
|
defer m.mutex.Unlock()
|
||||||
|
|
||||||
|
var merr *multierror.Error
|
||||||
|
|
||||||
if err := m.router.Reset(); err != nil {
|
if err := m.router.Reset(); err != nil {
|
||||||
return fmt.Errorf("reset router: %v", err)
|
merr = multierror.Append(merr, fmt.Errorf("reset router: %v", err))
|
||||||
|
}
|
||||||
|
|
||||||
|
if m.hasIPv6() {
|
||||||
|
if err := m.router6.Reset(); err != nil {
|
||||||
|
merr = multierror.Append(merr, fmt.Errorf("reset v6 router: %v", err))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := m.cleanupNetbirdTables(); err != nil {
|
if err := m.cleanupNetbirdTables(); err != nil {
|
||||||
return fmt.Errorf("cleanup netbird tables: %v", err)
|
merr = multierror.Append(merr, fmt.Errorf("cleanup netbird tables: %v", err))
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := m.rConn.Flush(); err != nil {
|
if err := m.rConn.Flush(); err != nil {
|
||||||
return fmt.Errorf(flushError, err)
|
merr = multierror.Append(merr, fmt.Errorf(flushError, err))
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := stateManager.DeleteState(&ShutdownState{}); err != nil {
|
if err := stateManager.DeleteState(&ShutdownState{}); err != nil {
|
||||||
return fmt.Errorf("delete state: %v", err)
|
merr = multierror.Append(merr, fmt.Errorf("delete state: %v", err))
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nberrors.FormatErrorOrNil(merr)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) cleanupNetbirdTables() error {
|
func (m *Manager) cleanupNetbirdTables() error {
|
||||||
@@ -288,7 +551,21 @@ func (m *Manager) Flush() error {
|
|||||||
m.mutex.Lock()
|
m.mutex.Lock()
|
||||||
defer m.mutex.Unlock()
|
defer m.mutex.Unlock()
|
||||||
|
|
||||||
return m.aclManager.Flush()
|
if err := m.aclManager.Flush(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if m.hasIPv6() {
|
||||||
|
if err := m.aclManager6.Flush(); err != nil {
|
||||||
|
return fmt.Errorf("flush v6 acl: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := m.refreshNoTrackChains(); err != nil {
|
||||||
|
log.Errorf("failed to refresh notrack chains: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// AddDNATRule adds a DNAT rule
|
// AddDNATRule adds a DNAT rule
|
||||||
@@ -296,6 +573,12 @@ func (m *Manager) AddDNATRule(rule firewall.ForwardRule) (firewall.Rule, error)
|
|||||||
m.mutex.Lock()
|
m.mutex.Lock()
|
||||||
defer m.mutex.Unlock()
|
defer m.mutex.Unlock()
|
||||||
|
|
||||||
|
if rule.TranslatedAddress.Is6() {
|
||||||
|
if !m.hasIPv6() {
|
||||||
|
return nil, fmt.Errorf("add DNAT rule: %w", firewall.ErrIPv6NotInitialized)
|
||||||
|
}
|
||||||
|
return m.router6.AddDNATRule(rule)
|
||||||
|
}
|
||||||
return m.router.AddDNATRule(rule)
|
return m.router.AddDNATRule(rule)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -304,7 +587,11 @@ func (m *Manager) DeleteDNATRule(rule firewall.Rule) error {
|
|||||||
m.mutex.Lock()
|
m.mutex.Lock()
|
||||||
defer m.mutex.Unlock()
|
defer m.mutex.Unlock()
|
||||||
|
|
||||||
return m.router.DeleteDNATRule(rule)
|
r, err := m.routerForRuleID(rule.ID(), (*router).hasDNATRule)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return r.DeleteDNATRule(rule)
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateSet updates the set with the given prefixes
|
// UpdateSet updates the set with the given prefixes
|
||||||
@@ -312,27 +599,260 @@ func (m *Manager) UpdateSet(set firewall.Set, prefixes []netip.Prefix) error {
|
|||||||
m.mutex.Lock()
|
m.mutex.Lock()
|
||||||
defer m.mutex.Unlock()
|
defer m.mutex.Unlock()
|
||||||
|
|
||||||
return m.router.UpdateSet(set, prefixes)
|
var v4Prefixes, v6Prefixes []netip.Prefix
|
||||||
|
for _, p := range prefixes {
|
||||||
|
if p.Addr().Is6() {
|
||||||
|
v6Prefixes = append(v6Prefixes, p)
|
||||||
|
} else {
|
||||||
|
v4Prefixes = append(v4Prefixes, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := m.router.UpdateSet(set, v4Prefixes); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if m.hasIPv6() && len(v6Prefixes) > 0 {
|
||||||
|
if err := m.router6.UpdateSet(set, v6Prefixes); err != nil {
|
||||||
|
return fmt.Errorf("update v6 set: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// AddInboundDNAT adds an inbound DNAT rule redirecting traffic from NetBird peers to local services.
|
// AddInboundDNAT adds an inbound DNAT rule redirecting traffic from NetBird peers to local services.
|
||||||
func (m *Manager) AddInboundDNAT(localAddr netip.Addr, protocol firewall.Protocol, sourcePort, targetPort uint16) error {
|
func (m *Manager) AddInboundDNAT(localAddr netip.Addr, protocol firewall.Protocol, originalPort, translatedPort uint16) error {
|
||||||
m.mutex.Lock()
|
m.mutex.Lock()
|
||||||
defer m.mutex.Unlock()
|
defer m.mutex.Unlock()
|
||||||
|
|
||||||
return m.router.AddInboundDNAT(localAddr, protocol, sourcePort, targetPort)
|
if localAddr.Is6() {
|
||||||
|
if !m.hasIPv6() {
|
||||||
|
return fmt.Errorf("add inbound DNAT: %w", firewall.ErrIPv6NotInitialized)
|
||||||
|
}
|
||||||
|
return m.router6.AddInboundDNAT(localAddr, protocol, originalPort, translatedPort)
|
||||||
|
}
|
||||||
|
return m.router.AddInboundDNAT(localAddr, protocol, originalPort, translatedPort)
|
||||||
}
|
}
|
||||||
|
|
||||||
// RemoveInboundDNAT removes an inbound DNAT rule.
|
// RemoveInboundDNAT removes an inbound DNAT rule.
|
||||||
func (m *Manager) RemoveInboundDNAT(localAddr netip.Addr, protocol firewall.Protocol, sourcePort, targetPort uint16) error {
|
func (m *Manager) RemoveInboundDNAT(localAddr netip.Addr, protocol firewall.Protocol, originalPort, translatedPort uint16) error {
|
||||||
m.mutex.Lock()
|
m.mutex.Lock()
|
||||||
defer m.mutex.Unlock()
|
defer m.mutex.Unlock()
|
||||||
|
|
||||||
return m.router.RemoveInboundDNAT(localAddr, protocol, sourcePort, targetPort)
|
if localAddr.Is6() {
|
||||||
|
if !m.hasIPv6() {
|
||||||
|
return fmt.Errorf("remove inbound DNAT: %w", firewall.ErrIPv6NotInitialized)
|
||||||
|
}
|
||||||
|
return m.router6.RemoveInboundDNAT(localAddr, protocol, originalPort, translatedPort)
|
||||||
|
}
|
||||||
|
return m.router.RemoveInboundDNAT(localAddr, protocol, originalPort, translatedPort)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddOutputDNAT adds an OUTPUT chain DNAT rule for locally-generated traffic.
|
||||||
|
func (m *Manager) AddOutputDNAT(localAddr netip.Addr, protocol firewall.Protocol, originalPort, translatedPort uint16) error {
|
||||||
|
m.mutex.Lock()
|
||||||
|
defer m.mutex.Unlock()
|
||||||
|
|
||||||
|
if localAddr.Is6() {
|
||||||
|
if !m.hasIPv6() {
|
||||||
|
return fmt.Errorf("add output DNAT: %w", firewall.ErrIPv6NotInitialized)
|
||||||
|
}
|
||||||
|
return m.router6.AddOutputDNAT(localAddr, protocol, originalPort, translatedPort)
|
||||||
|
}
|
||||||
|
return m.router.AddOutputDNAT(localAddr, protocol, originalPort, translatedPort)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoveOutputDNAT removes an OUTPUT chain DNAT rule.
|
||||||
|
func (m *Manager) RemoveOutputDNAT(localAddr netip.Addr, protocol firewall.Protocol, originalPort, translatedPort uint16) error {
|
||||||
|
m.mutex.Lock()
|
||||||
|
defer m.mutex.Unlock()
|
||||||
|
|
||||||
|
if localAddr.Is6() {
|
||||||
|
if !m.hasIPv6() {
|
||||||
|
return fmt.Errorf("remove output DNAT: %w", firewall.ErrIPv6NotInitialized)
|
||||||
|
}
|
||||||
|
return m.router6.RemoveOutputDNAT(localAddr, protocol, originalPort, translatedPort)
|
||||||
|
}
|
||||||
|
return m.router.RemoveOutputDNAT(localAddr, protocol, originalPort, translatedPort)
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
chainNameRawOutput = "netbird-raw-out"
|
||||||
|
chainNameRawPrerouting = "netbird-raw-pre"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SetupEBPFProxyNoTrack creates notrack rules for eBPF proxy loopback traffic.
|
||||||
|
// This prevents conntrack from tracking WireGuard proxy traffic on loopback, which
|
||||||
|
// can interfere with MASQUERADE rules (e.g., from container runtimes like Podman/netavark).
|
||||||
|
//
|
||||||
|
// Traffic flows that need NOTRACK:
|
||||||
|
//
|
||||||
|
// 1. Egress: WireGuard -> fake endpoint (before eBPF rewrite)
|
||||||
|
// src=127.0.0.1:wgPort -> dst=127.0.0.1:fakePort
|
||||||
|
// Matched by: sport=wgPort
|
||||||
|
//
|
||||||
|
// 2. Egress: Proxy -> WireGuard (via raw socket)
|
||||||
|
// src=127.0.0.1:fakePort -> dst=127.0.0.1:wgPort
|
||||||
|
// Matched by: dport=wgPort
|
||||||
|
//
|
||||||
|
// 3. Ingress: Packets to WireGuard
|
||||||
|
// dst=127.0.0.1:wgPort
|
||||||
|
// Matched by: dport=wgPort
|
||||||
|
//
|
||||||
|
// 4. Ingress: Packets to proxy (after eBPF rewrite)
|
||||||
|
// dst=127.0.0.1:proxyPort
|
||||||
|
// Matched by: dport=proxyPort
|
||||||
|
//
|
||||||
|
// Rules are cleaned up when the firewall manager is closed.
|
||||||
|
func (m *Manager) SetupEBPFProxyNoTrack(proxyPort, wgPort uint16) error {
|
||||||
|
m.mutex.Lock()
|
||||||
|
defer m.mutex.Unlock()
|
||||||
|
|
||||||
|
if m.notrackOutputChain == nil || m.notrackPreroutingChain == nil {
|
||||||
|
return fmt.Errorf("notrack chains not initialized")
|
||||||
|
}
|
||||||
|
|
||||||
|
proxyPortBytes := binaryutil.BigEndian.PutUint16(proxyPort)
|
||||||
|
wgPortBytes := binaryutil.BigEndian.PutUint16(wgPort)
|
||||||
|
loopback := []byte{127, 0, 0, 1}
|
||||||
|
|
||||||
|
// Egress rules: match outgoing loopback UDP packets
|
||||||
|
m.rConn.AddRule(&nftables.Rule{
|
||||||
|
Table: m.notrackOutputChain.Table,
|
||||||
|
Chain: m.notrackOutputChain,
|
||||||
|
Exprs: []expr.Any{
|
||||||
|
&expr.Meta{Key: expr.MetaKeyOIFNAME, Register: 1},
|
||||||
|
&expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: ifname("lo")},
|
||||||
|
&expr.Payload{DestRegister: 1, Base: expr.PayloadBaseNetworkHeader, Offset: 12, Len: 4}, // saddr
|
||||||
|
&expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: loopback},
|
||||||
|
&expr.Payload{DestRegister: 1, Base: expr.PayloadBaseNetworkHeader, Offset: 16, Len: 4}, // daddr
|
||||||
|
&expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: loopback},
|
||||||
|
&expr.Meta{Key: expr.MetaKeyL4PROTO, Register: 1},
|
||||||
|
&expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: []byte{unix.IPPROTO_UDP}},
|
||||||
|
&expr.Payload{DestRegister: 1, Base: expr.PayloadBaseTransportHeader, Offset: 0, Len: 2},
|
||||||
|
&expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: wgPortBytes}, // sport=wgPort
|
||||||
|
&expr.Counter{},
|
||||||
|
&expr.Notrack{},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
m.rConn.AddRule(&nftables.Rule{
|
||||||
|
Table: m.notrackOutputChain.Table,
|
||||||
|
Chain: m.notrackOutputChain,
|
||||||
|
Exprs: []expr.Any{
|
||||||
|
&expr.Meta{Key: expr.MetaKeyOIFNAME, Register: 1},
|
||||||
|
&expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: ifname("lo")},
|
||||||
|
&expr.Payload{DestRegister: 1, Base: expr.PayloadBaseNetworkHeader, Offset: 12, Len: 4}, // saddr
|
||||||
|
&expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: loopback},
|
||||||
|
&expr.Payload{DestRegister: 1, Base: expr.PayloadBaseNetworkHeader, Offset: 16, Len: 4}, // daddr
|
||||||
|
&expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: loopback},
|
||||||
|
&expr.Meta{Key: expr.MetaKeyL4PROTO, Register: 1},
|
||||||
|
&expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: []byte{unix.IPPROTO_UDP}},
|
||||||
|
&expr.Payload{DestRegister: 1, Base: expr.PayloadBaseTransportHeader, Offset: 2, Len: 2},
|
||||||
|
&expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: wgPortBytes}, // dport=wgPort
|
||||||
|
&expr.Counter{},
|
||||||
|
&expr.Notrack{},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Ingress rules: match incoming loopback UDP packets
|
||||||
|
m.rConn.AddRule(&nftables.Rule{
|
||||||
|
Table: m.notrackPreroutingChain.Table,
|
||||||
|
Chain: m.notrackPreroutingChain,
|
||||||
|
Exprs: []expr.Any{
|
||||||
|
&expr.Meta{Key: expr.MetaKeyIIFNAME, Register: 1},
|
||||||
|
&expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: ifname("lo")},
|
||||||
|
&expr.Payload{DestRegister: 1, Base: expr.PayloadBaseNetworkHeader, Offset: 12, Len: 4}, // saddr
|
||||||
|
&expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: loopback},
|
||||||
|
&expr.Payload{DestRegister: 1, Base: expr.PayloadBaseNetworkHeader, Offset: 16, Len: 4}, // daddr
|
||||||
|
&expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: loopback},
|
||||||
|
&expr.Meta{Key: expr.MetaKeyL4PROTO, Register: 1},
|
||||||
|
&expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: []byte{unix.IPPROTO_UDP}},
|
||||||
|
&expr.Payload{DestRegister: 1, Base: expr.PayloadBaseTransportHeader, Offset: 2, Len: 2},
|
||||||
|
&expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: wgPortBytes}, // dport=wgPort
|
||||||
|
&expr.Counter{},
|
||||||
|
&expr.Notrack{},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
m.rConn.AddRule(&nftables.Rule{
|
||||||
|
Table: m.notrackPreroutingChain.Table,
|
||||||
|
Chain: m.notrackPreroutingChain,
|
||||||
|
Exprs: []expr.Any{
|
||||||
|
&expr.Meta{Key: expr.MetaKeyIIFNAME, Register: 1},
|
||||||
|
&expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: ifname("lo")},
|
||||||
|
&expr.Payload{DestRegister: 1, Base: expr.PayloadBaseNetworkHeader, Offset: 12, Len: 4}, // saddr
|
||||||
|
&expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: loopback},
|
||||||
|
&expr.Payload{DestRegister: 1, Base: expr.PayloadBaseNetworkHeader, Offset: 16, Len: 4}, // daddr
|
||||||
|
&expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: loopback},
|
||||||
|
&expr.Meta{Key: expr.MetaKeyL4PROTO, Register: 1},
|
||||||
|
&expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: []byte{unix.IPPROTO_UDP}},
|
||||||
|
&expr.Payload{DestRegister: 1, Base: expr.PayloadBaseTransportHeader, Offset: 2, Len: 2},
|
||||||
|
&expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: proxyPortBytes}, // dport=proxyPort
|
||||||
|
&expr.Counter{},
|
||||||
|
&expr.Notrack{},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if err := m.rConn.Flush(); err != nil {
|
||||||
|
return fmt.Errorf("flush notrack rules: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Debugf("set up ebpf proxy notrack rules for ports %d,%d", proxyPort, wgPort)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) initNoTrackChains(table *nftables.Table) error {
|
||||||
|
m.notrackOutputChain = m.rConn.AddChain(&nftables.Chain{
|
||||||
|
Name: chainNameRawOutput,
|
||||||
|
Table: table,
|
||||||
|
Type: nftables.ChainTypeFilter,
|
||||||
|
Hooknum: nftables.ChainHookOutput,
|
||||||
|
Priority: nftables.ChainPriorityRaw,
|
||||||
|
})
|
||||||
|
|
||||||
|
m.notrackPreroutingChain = m.rConn.AddChain(&nftables.Chain{
|
||||||
|
Name: chainNameRawPrerouting,
|
||||||
|
Table: table,
|
||||||
|
Type: nftables.ChainTypeFilter,
|
||||||
|
Hooknum: nftables.ChainHookPrerouting,
|
||||||
|
Priority: nftables.ChainPriorityRaw,
|
||||||
|
})
|
||||||
|
|
||||||
|
if err := m.rConn.Flush(); err != nil {
|
||||||
|
return fmt.Errorf("flush chain creation: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) refreshNoTrackChains() error {
|
||||||
|
chains, err := m.rConn.ListChainsOfTableFamily(nftables.TableFamilyIPv4)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("list chains: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tableName := getTableName()
|
||||||
|
for _, c := range chains {
|
||||||
|
if c.Table.Name != tableName {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch c.Name {
|
||||||
|
case chainNameRawOutput:
|
||||||
|
m.notrackOutputChain = c
|
||||||
|
case chainNameRawPrerouting:
|
||||||
|
m.notrackPreroutingChain = c
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) createWorkTable() (*nftables.Table, error) {
|
func (m *Manager) createWorkTable() (*nftables.Table, error) {
|
||||||
tables, err := m.rConn.ListTablesOfFamily(nftables.TableFamilyIPv4)
|
return m.createWorkTableFamily(nftables.TableFamilyIPv4)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) createWorkTableFamily(family nftables.TableFamily) (*nftables.Table, error) {
|
||||||
|
tables, err := m.rConn.ListTablesOfFamily(family)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("list of tables: %w", err)
|
return nil, fmt.Errorf("list of tables: %w", err)
|
||||||
}
|
}
|
||||||
@@ -344,7 +864,7 @@ func (m *Manager) createWorkTable() (*nftables.Table, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
table := m.rConn.AddTable(&nftables.Table{Name: getTableName(), Family: nftables.TableFamilyIPv4})
|
table := m.rConn.AddTable(&nftables.Table{Name: tableName, Family: family})
|
||||||
err = m.rConn.Flush()
|
err = m.rConn.Flush()
|
||||||
return table, err
|
return table, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,8 +52,6 @@ func (i *iFaceMock) Address() wgaddr.Address {
|
|||||||
panic("AddressFunc is not set")
|
panic("AddressFunc is not set")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (i *iFaceMock) IsUserspaceBind() bool { return false }
|
|
||||||
|
|
||||||
func TestNftablesManager(t *testing.T) {
|
func TestNftablesManager(t *testing.T) {
|
||||||
|
|
||||||
// just check on the local interface
|
// just check on the local interface
|
||||||
@@ -198,7 +196,7 @@ func TestNftablesManagerRuleOrder(t *testing.T) {
|
|||||||
t.Logf("Found %d rules in nftables chain", len(rules))
|
t.Logf("Found %d rules in nftables chain", len(rules))
|
||||||
|
|
||||||
// Find the accept and deny rules and verify deny comes before accept
|
// Find the accept and deny rules and verify deny comes before accept
|
||||||
var acceptRuleIndex, denyRuleIndex int = -1, -1
|
var acceptRuleIndex, denyRuleIndex = -1, -1
|
||||||
for i, rule := range rules {
|
for i, rule := range rules {
|
||||||
hasAcceptHTTPSet := false
|
hasAcceptHTTPSet := false
|
||||||
hasDenyHTTPSet := false
|
hasDenyHTTPSet := false
|
||||||
@@ -208,11 +206,13 @@ func TestNftablesManagerRuleOrder(t *testing.T) {
|
|||||||
for _, e := range rule.Exprs {
|
for _, e := range rule.Exprs {
|
||||||
// Check for set lookup
|
// Check for set lookup
|
||||||
if lookup, ok := e.(*expr.Lookup); ok {
|
if lookup, ok := e.(*expr.Lookup); ok {
|
||||||
if lookup.SetName == "accept-http" {
|
switch lookup.SetName {
|
||||||
|
case "accept-http":
|
||||||
hasAcceptHTTPSet = true
|
hasAcceptHTTPSet = true
|
||||||
} else if lookup.SetName == "deny-http" {
|
case "deny-http":
|
||||||
hasDenyHTTPSet = true
|
hasDenyHTTPSet = true
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
// Check for port 80
|
// Check for port 80
|
||||||
if cmp, ok := e.(*expr.Cmp); ok {
|
if cmp, ok := e.(*expr.Cmp); ok {
|
||||||
@@ -222,9 +222,10 @@ func TestNftablesManagerRuleOrder(t *testing.T) {
|
|||||||
}
|
}
|
||||||
// Check for verdict
|
// Check for verdict
|
||||||
if verdict, ok := e.(*expr.Verdict); ok {
|
if verdict, ok := e.(*expr.Verdict); ok {
|
||||||
if verdict.Kind == expr.VerdictAccept {
|
switch verdict.Kind {
|
||||||
|
case expr.VerdictAccept:
|
||||||
action = "ACCEPT"
|
action = "ACCEPT"
|
||||||
} else if verdict.Kind == expr.VerdictDrop {
|
case expr.VerdictDrop:
|
||||||
action = "DROP"
|
action = "DROP"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -382,6 +383,225 @@ func TestNftablesManagerCompatibilityWithIptables(t *testing.T) {
|
|||||||
err = manager.AddNatRule(pair)
|
err = manager.AddNatRule(pair)
|
||||||
require.NoError(t, err, "failed to add NAT rule")
|
require.NoError(t, err, "failed to add NAT rule")
|
||||||
|
|
||||||
|
dnatRule, err := manager.AddDNATRule(fw.ForwardRule{
|
||||||
|
Protocol: fw.ProtocolTCP,
|
||||||
|
DestinationPort: fw.Port{Values: []uint16{8080}},
|
||||||
|
TranslatedAddress: netip.MustParseAddr("100.96.0.2"),
|
||||||
|
TranslatedPort: fw.Port{Values: []uint16{80}},
|
||||||
|
})
|
||||||
|
require.NoError(t, err, "failed to add DNAT rule")
|
||||||
|
|
||||||
|
t.Cleanup(func() {
|
||||||
|
require.NoError(t, manager.DeleteDNATRule(dnatRule), "failed to delete DNAT rule")
|
||||||
|
})
|
||||||
|
|
||||||
|
stdout, stderr = runIptablesSave(t)
|
||||||
|
verifyIptablesOutput(t, stdout, stderr)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNftablesManagerIPv6CompatibilityWithIp6tables(t *testing.T) {
|
||||||
|
if check() != NFTABLES {
|
||||||
|
t.Skip("nftables not supported on this system")
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, bin := range []string{"ip6tables", "ip6tables-save", "iptables-save"} {
|
||||||
|
if _, err := exec.LookPath(bin); err != nil {
|
||||||
|
t.Skipf("%s not available on this system: %v", bin, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Seed ip6 tables in the nft backend. Docker may not create them.
|
||||||
|
seedIp6tables(t)
|
||||||
|
|
||||||
|
ifaceMockV6 := &iFaceMock{
|
||||||
|
NameFunc: func() string { return "wt-test" },
|
||||||
|
AddressFunc: func() wgaddr.Address {
|
||||||
|
return wgaddr.Address{
|
||||||
|
IP: netip.MustParseAddr("100.96.0.1"),
|
||||||
|
Network: netip.MustParsePrefix("100.96.0.0/16"),
|
||||||
|
IPv6: netip.MustParseAddr("fd00::1"),
|
||||||
|
IPv6Net: netip.MustParsePrefix("fd00::/64"),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
manager, err := Create(ifaceMockV6, iface.DefaultMTU)
|
||||||
|
require.NoError(t, err, "create manager")
|
||||||
|
require.NoError(t, manager.Init(nil))
|
||||||
|
|
||||||
|
t.Cleanup(func() {
|
||||||
|
require.NoError(t, manager.Close(nil), "close manager")
|
||||||
|
|
||||||
|
stdout, stderr := runIp6tablesSave(t)
|
||||||
|
verifyIp6tablesOutput(t, stdout, stderr)
|
||||||
|
})
|
||||||
|
|
||||||
|
ip := netip.MustParseAddr("fd00::2")
|
||||||
|
_, err = manager.AddPeerFiltering(nil, ip.AsSlice(), fw.ProtocolTCP, nil, &fw.Port{Values: []uint16{80}}, fw.ActionAccept, "")
|
||||||
|
require.NoError(t, err, "add v6 peer filtering rule")
|
||||||
|
|
||||||
|
_, err = manager.AddRouteFiltering(
|
||||||
|
nil,
|
||||||
|
[]netip.Prefix{netip.MustParsePrefix("fd00:1::/64")},
|
||||||
|
fw.Network{Prefix: netip.MustParsePrefix("2001:db8::/48")},
|
||||||
|
fw.ProtocolTCP,
|
||||||
|
nil,
|
||||||
|
&fw.Port{Values: []uint16{443}},
|
||||||
|
fw.ActionAccept,
|
||||||
|
)
|
||||||
|
require.NoError(t, err, "add v6 route filtering rule")
|
||||||
|
|
||||||
|
err = manager.AddNatRule(fw.RouterPair{
|
||||||
|
Source: fw.Network{Prefix: netip.MustParsePrefix("fd00::/64")},
|
||||||
|
Destination: fw.Network{Prefix: netip.MustParsePrefix("2001:db8::/48")},
|
||||||
|
Masquerade: true,
|
||||||
|
})
|
||||||
|
require.NoError(t, err, "add v6 NAT rule")
|
||||||
|
|
||||||
|
dnatRule, err := manager.AddDNATRule(fw.ForwardRule{
|
||||||
|
Protocol: fw.ProtocolTCP,
|
||||||
|
DestinationPort: fw.Port{Values: []uint16{8080}},
|
||||||
|
TranslatedAddress: netip.MustParseAddr("fd00::2"),
|
||||||
|
TranslatedPort: fw.Port{Values: []uint16{80}},
|
||||||
|
})
|
||||||
|
require.NoError(t, err, "add v6 DNAT rule")
|
||||||
|
|
||||||
|
t.Cleanup(func() {
|
||||||
|
require.NoError(t, manager.DeleteDNATRule(dnatRule), "delete v6 DNAT rule")
|
||||||
|
})
|
||||||
|
|
||||||
|
stdout, stderr := runIptablesSave(t)
|
||||||
|
verifyIptablesOutput(t, stdout, stderr)
|
||||||
|
|
||||||
|
stdout, stderr = runIp6tablesSave(t)
|
||||||
|
verifyIp6tablesOutput(t, stdout, stderr)
|
||||||
|
}
|
||||||
|
|
||||||
|
func seedIp6tables(t *testing.T) {
|
||||||
|
t.Helper()
|
||||||
|
for _, tc := range []struct{ table, chain string }{
|
||||||
|
{"filter", "FORWARD"},
|
||||||
|
{"nat", "POSTROUTING"},
|
||||||
|
{"mangle", "FORWARD"},
|
||||||
|
} {
|
||||||
|
add := exec.Command("ip6tables", "-t", tc.table, "-A", tc.chain, "-j", "ACCEPT")
|
||||||
|
require.NoError(t, add.Run(), "seed ip6tables -t %s", tc.table)
|
||||||
|
del := exec.Command("ip6tables", "-t", tc.table, "-D", tc.chain, "-j", "ACCEPT")
|
||||||
|
require.NoError(t, del.Run(), "unseed ip6tables -t %s", tc.table)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func runIp6tablesSave(t *testing.T) (string, string) {
|
||||||
|
t.Helper()
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
cmd := exec.Command("ip6tables-save")
|
||||||
|
cmd.Stdout = &stdout
|
||||||
|
cmd.Stderr = &stderr
|
||||||
|
require.NoError(t, cmd.Run(), "ip6tables-save failed")
|
||||||
|
return stdout.String(), stderr.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func verifyIp6tablesOutput(t *testing.T, stdout, stderr string) {
|
||||||
|
t.Helper()
|
||||||
|
for _, msg := range []string{
|
||||||
|
"Table `nat' is incompatible",
|
||||||
|
"Table `mangle' is incompatible",
|
||||||
|
"Table `filter' is incompatible",
|
||||||
|
} {
|
||||||
|
require.NotContains(t, stdout, msg,
|
||||||
|
"ip6tables-save stdout reports incompatibility: %s", stdout)
|
||||||
|
require.NotContains(t, stderr, msg,
|
||||||
|
"ip6tables-save stderr reports incompatibility: %s", stderr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNftablesManagerCompatibilityWithIptablesFor6kPrefixes(t *testing.T) {
|
||||||
|
if check() != NFTABLES {
|
||||||
|
t.Skip("nftables not supported on this system")
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := exec.LookPath("iptables-save"); err != nil {
|
||||||
|
t.Skipf("iptables-save not available on this system: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// First ensure iptables-nft tables exist by running iptables-save
|
||||||
|
stdout, stderr := runIptablesSave(t)
|
||||||
|
verifyIptablesOutput(t, stdout, stderr)
|
||||||
|
|
||||||
|
manager, err := Create(ifaceMock, iface.DefaultMTU)
|
||||||
|
require.NoError(t, err, "failed to create manager")
|
||||||
|
require.NoError(t, manager.Init(nil))
|
||||||
|
|
||||||
|
t.Cleanup(func() {
|
||||||
|
err := manager.Close(nil)
|
||||||
|
require.NoError(t, err, "failed to reset manager state")
|
||||||
|
|
||||||
|
// Verify iptables output after reset
|
||||||
|
stdout, stderr := runIptablesSave(t)
|
||||||
|
verifyIptablesOutput(t, stdout, stderr)
|
||||||
|
})
|
||||||
|
|
||||||
|
const octet2Count = 25
|
||||||
|
const octet3Count = 255
|
||||||
|
prefixes := make([]netip.Prefix, 0, (octet2Count-1)*(octet3Count-1))
|
||||||
|
for i := 1; i < octet2Count; i++ {
|
||||||
|
for j := 1; j < octet3Count; j++ {
|
||||||
|
addr := netip.AddrFrom4([4]byte{192, byte(j), byte(i), 0})
|
||||||
|
prefixes = append(prefixes, netip.PrefixFrom(addr, 24))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_, err = manager.AddRouteFiltering(
|
||||||
|
nil,
|
||||||
|
prefixes,
|
||||||
|
fw.Network{Prefix: netip.MustParsePrefix("10.2.0.0/24")},
|
||||||
|
fw.ProtocolTCP,
|
||||||
|
nil,
|
||||||
|
&fw.Port{Values: []uint16{443}},
|
||||||
|
fw.ActionAccept,
|
||||||
|
)
|
||||||
|
require.NoError(t, err, "failed to add route filtering rule")
|
||||||
|
|
||||||
|
stdout, stderr = runIptablesSave(t)
|
||||||
|
verifyIptablesOutput(t, stdout, stderr)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNftablesManagerCompatibilityWithIptablesForEmptyPrefixes(t *testing.T) {
|
||||||
|
if check() != NFTABLES {
|
||||||
|
t.Skip("nftables not supported on this system")
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := exec.LookPath("iptables-save"); err != nil {
|
||||||
|
t.Skipf("iptables-save not available on this system: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// First ensure iptables-nft tables exist by running iptables-save
|
||||||
|
stdout, stderr := runIptablesSave(t)
|
||||||
|
verifyIptablesOutput(t, stdout, stderr)
|
||||||
|
|
||||||
|
manager, err := Create(ifaceMock, iface.DefaultMTU)
|
||||||
|
require.NoError(t, err, "failed to create manager")
|
||||||
|
require.NoError(t, manager.Init(nil))
|
||||||
|
|
||||||
|
t.Cleanup(func() {
|
||||||
|
err := manager.Close(nil)
|
||||||
|
require.NoError(t, err, "failed to reset manager state")
|
||||||
|
|
||||||
|
// Verify iptables output after reset
|
||||||
|
stdout, stderr := runIptablesSave(t)
|
||||||
|
verifyIptablesOutput(t, stdout, stderr)
|
||||||
|
})
|
||||||
|
|
||||||
|
_, err = manager.AddRouteFiltering(
|
||||||
|
nil,
|
||||||
|
[]netip.Prefix{},
|
||||||
|
fw.Network{Prefix: netip.MustParsePrefix("10.2.0.0/24")},
|
||||||
|
fw.ProtocolTCP,
|
||||||
|
nil,
|
||||||
|
&fw.Port{Values: []uint16{443}},
|
||||||
|
fw.ActionAccept,
|
||||||
|
)
|
||||||
|
require.NoError(t, err, "failed to add route filtering rule")
|
||||||
|
|
||||||
stdout, stderr = runIptablesSave(t)
|
stdout, stderr = runIptablesSave(t)
|
||||||
verifyIptablesOutput(t, stdout, stderr)
|
verifyIptablesOutput(t, stdout, stderr)
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user