Compare commits
162 Commits
test/proxy
...
flutter-te
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
555f5233cc | ||
|
|
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 |
@@ -31,7 +31,7 @@ jobs:
|
||||
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/" || true)
|
||||
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"
|
||||
@@ -88,7 +88,7 @@ jobs:
|
||||
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\)" | head -1)
|
||||
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"
|
||||
|
||||
9
.github/workflows/golang-test-windows.yml
vendored
@@ -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 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: echo "files=$(go list ./... | ForEach-Object { $_ } | Where-Object { $_ -notmatch '/management' } | Where-Object { $_ -notmatch '/relay' } | Where-Object { $_ -notmatch '/signal' } | Where-Object { $_ -notmatch '/proxy' } | Where-Object { $_ -notmatch '/combined' })" >> $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
|
||||
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
|
||||
if: ${{ always() }}
|
||||
run: Get-Content test-out.txt
|
||||
|
||||
2
.github/workflows/golangci-lint.yml
vendored
@@ -19,7 +19,7 @@ jobs:
|
||||
- name: codespell
|
||||
uses: codespell-project/actions-codespell@v2
|
||||
with:
|
||||
ignore_words_list: erro,clienta,hastable,iif,groupd,testin,groupe,cros,ans,deriver
|
||||
ignore_words_list: erro,clienta,hastable,iif,groupd,testin,groupe,cros,ans,deriver,te,userA
|
||||
skip: go.mod,go.sum,**/proxy/web/**
|
||||
golangci:
|
||||
strategy:
|
||||
|
||||
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
@@ -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');
|
||||
90
.github/workflows/release.yml
vendored
@@ -9,8 +9,8 @@ on:
|
||||
pull_request:
|
||||
|
||||
env:
|
||||
SIGN_PIPE_VER: "v0.1.1"
|
||||
GORELEASER_VER: "v2.3.2"
|
||||
SIGN_PIPE_VER: "v0.1.4"
|
||||
GORELEASER_VER: "v2.14.3"
|
||||
PRODUCT_NAME: "NetBird"
|
||||
COPYRIGHT: "NetBird GmbH"
|
||||
|
||||
@@ -169,6 +169,14 @@ jobs:
|
||||
- name: Install OS build dependencies
|
||||
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
|
||||
run: go install github.com/josephspurrier/goversioninfo/cmd/goversioninfo@233067e
|
||||
- name: Generate windows syso amd64
|
||||
@@ -186,18 +194,54 @@ jobs:
|
||||
HOMEBREW_TAP_GITHUB_TOKEN: ${{ secrets.HOMEBREW_TAP_GITHUB_TOKEN }}
|
||||
UPLOAD_DEBIAN_SECRET: ${{ secrets.PKG_UPLOAD_SECRET }}
|
||||
UPLOAD_YUM_SECRET: ${{ secrets.PKG_UPLOAD_SECRET }}
|
||||
- name: Tag and push PR images (amd64 only)
|
||||
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository
|
||||
GPG_RPM_KEY_FILE: ${{ env.GPG_RPM_KEY_FILE }}
|
||||
NFPM_NETBIRD_RPM_PASSPHRASE: ${{ secrets.GPG_RPM_PASSPHRASE }}
|
||||
- name: Verify RPM signatures
|
||||
run: |
|
||||
PR_TAG="pr-${{ github.event.pull_request.number }}"
|
||||
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)
|
||||
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: |
|
||||
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
|
||||
}
|
||||
|
||||
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"
|
||||
done
|
||||
}
|
||||
|
||||
export -f tag_and_push resolve_tags
|
||||
|
||||
echo '${{ steps.goreleaser.outputs.artifacts }}' | \
|
||||
jq -r '.[] | select(.type == "Docker Image") | select(.goarch == "amd64") | .name' | \
|
||||
grep '^ghcr.io/' | while read -r SRC; do
|
||||
IMG_NAME="${SRC%%:*}"
|
||||
DST="${IMG_NAME}:${PR_TAG}"
|
||||
echo "Tagging ${SRC} -> ${DST}"
|
||||
docker tag "$SRC" "$DST"
|
||||
docker push "$DST"
|
||||
tag_and_push "$SRC"
|
||||
done
|
||||
- name: upload non tags for debug purposes
|
||||
uses: actions/upload-artifact@v4
|
||||
@@ -265,6 +309,14 @@ jobs:
|
||||
- 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
|
||||
|
||||
- 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
|
||||
run: |
|
||||
cd /tmp
|
||||
@@ -289,6 +341,24 @@ jobs:
|
||||
HOMEBREW_TAP_GITHUB_TOKEN: ${{ secrets.HOMEBREW_TAP_GITHUB_TOKEN }}
|
||||
UPLOAD_DEBIAN_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
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
|
||||
4
.github/workflows/wasm-build-validation.yml
vendored
@@ -61,8 +61,8 @@ jobs:
|
||||
|
||||
echo "Size: ${SIZE} bytes (${SIZE_MB} MB)"
|
||||
|
||||
if [ ${SIZE} -gt 57671680 ]; then
|
||||
echo "Wasm binary size (${SIZE_MB}MB) exceeds 55MB limit!"
|
||||
if [ ${SIZE} -gt 58720256 ]; then
|
||||
echo "Wasm binary size (${SIZE_MB}MB) exceeds 56MB limit!"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
||||
@@ -154,6 +154,26 @@ builds:
|
||||
- -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:
|
||||
- id: netbird
|
||||
|
||||
@@ -166,18 +186,22 @@ archives:
|
||||
- netbird-wasm
|
||||
name_template: "{{ .ProjectName }}_{{ .Version }}"
|
||||
format: binary
|
||||
- id: netbird-idp-migrate
|
||||
builds:
|
||||
- netbird-idp-migrate
|
||||
name_template: "netbird-idp-migrate_{{ .Version }}_{{ .Os }}_{{ .Arch }}"
|
||||
|
||||
nfpms:
|
||||
- maintainer: Netbird <dev@netbird.io>
|
||||
description: Netbird client.
|
||||
homepage: https://netbird.io/
|
||||
id: netbird-deb
|
||||
license: BSD-3-Clause
|
||||
id: netbird_deb
|
||||
bindir: /usr/bin
|
||||
builds:
|
||||
- netbird
|
||||
formats:
|
||||
- deb
|
||||
|
||||
scripts:
|
||||
postinstall: "release_files/post_install.sh"
|
||||
preremove: "release_files/pre_remove.sh"
|
||||
@@ -185,16 +209,19 @@ nfpms:
|
||||
- maintainer: Netbird <dev@netbird.io>
|
||||
description: Netbird client.
|
||||
homepage: https://netbird.io/
|
||||
id: netbird-rpm
|
||||
license: BSD-3-Clause
|
||||
id: netbird_rpm
|
||||
bindir: /usr/bin
|
||||
builds:
|
||||
- netbird
|
||||
formats:
|
||||
- rpm
|
||||
|
||||
scripts:
|
||||
postinstall: "release_files/post_install.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:
|
||||
- image_templates:
|
||||
- netbirdio/netbird:{{ .Version }}-amd64
|
||||
@@ -876,7 +903,7 @@ brews:
|
||||
uploads:
|
||||
- name: debian
|
||||
ids:
|
||||
- netbird-deb
|
||||
- netbird_deb
|
||||
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=
|
||||
username: dev@wiretrustee.com
|
||||
@@ -884,7 +911,7 @@ uploads:
|
||||
|
||||
- name: yum
|
||||
ids:
|
||||
- netbird-rpm
|
||||
- netbird_rpm
|
||||
mode: archive
|
||||
target: https://pkgs.wiretrustee.com/yum/{{ .Arch }}{{ if .Arm }}{{ .Arm }}{{ end }}
|
||||
username: dev@wiretrustee.com
|
||||
|
||||
@@ -61,7 +61,7 @@ nfpms:
|
||||
- maintainer: Netbird <dev@netbird.io>
|
||||
description: Netbird client UI.
|
||||
homepage: https://netbird.io/
|
||||
id: netbird-ui-deb
|
||||
id: netbird_ui_deb
|
||||
package_name: netbird-ui
|
||||
builds:
|
||||
- netbird-ui
|
||||
@@ -80,7 +80,7 @@ nfpms:
|
||||
- maintainer: Netbird <dev@netbird.io>
|
||||
description: Netbird client UI.
|
||||
homepage: https://netbird.io/
|
||||
id: netbird-ui-rpm
|
||||
id: netbird_ui_rpm
|
||||
package_name: netbird-ui
|
||||
builds:
|
||||
- netbird-ui
|
||||
@@ -95,11 +95,14 @@ nfpms:
|
||||
dst: /usr/share/pixmaps/netbird.png
|
||||
dependencies:
|
||||
- netbird
|
||||
rpm:
|
||||
signature:
|
||||
key_file: '{{ if index .Env "GPG_RPM_KEY_FILE" }}{{ .Env.GPG_RPM_KEY_FILE }}{{ end }}'
|
||||
|
||||
uploads:
|
||||
- name: debian
|
||||
ids:
|
||||
- netbird-ui-deb
|
||||
- netbird_ui_deb
|
||||
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=
|
||||
username: dev@wiretrustee.com
|
||||
@@ -107,7 +110,7 @@ uploads:
|
||||
|
||||
- name: yum
|
||||
ids:
|
||||
- netbird-ui-rpm
|
||||
- netbird_ui_rpm
|
||||
mode: archive
|
||||
target: https://pkgs.wiretrustee.com/yum/{{ .Arch }}{{ if .Arm }}{{ .Arm }}{{ end }}
|
||||
username: dev@wiretrustee.com
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
## Contributor License Agreement
|
||||
|
||||
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
|
||||
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
|
||||
|
||||
2
Makefile
@@ -5,7 +5,7 @@ GOLANGCI_LINT := $(shell pwd)/bin/golangci-lint
|
||||
$(GOLANGCI_LINT):
|
||||
@echo "Installing golangci-lint..."
|
||||
@mkdir -p ./bin
|
||||
@GOBIN=$(shell pwd)/bin go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest
|
||||
@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)
|
||||
|
||||
@@ -126,6 +126,7 @@ See a complete [architecture overview](https://docs.netbird.io/about-netbird/how
|
||||
### Community projects
|
||||
- [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-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.
|
||||
For stable versions, see [releases](https://github.com/netbirdio/netbird/releases).
|
||||
|
||||
@@ -17,8 +17,7 @@ ENV \
|
||||
NETBIRD_BIN="/usr/local/bin/netbird" \
|
||||
NB_LOG_FILE="console,/var/log/netbird/client.log" \
|
||||
NB_DAEMON_ADDR="unix:///var/run/netbird.sock" \
|
||||
NB_ENTRYPOINT_SERVICE_TIMEOUT="5" \
|
||||
NB_ENTRYPOINT_LOGIN_TIMEOUT="5"
|
||||
NB_ENTRYPOINT_SERVICE_TIMEOUT="30"
|
||||
|
||||
ENTRYPOINT [ "/usr/local/bin/netbird-entrypoint.sh" ]
|
||||
|
||||
|
||||
@@ -23,8 +23,7 @@ ENV \
|
||||
NB_DAEMON_ADDR="unix:///var/lib/netbird/netbird.sock" \
|
||||
NB_LOG_FILE="console,/var/lib/netbird/client.log" \
|
||||
NB_DISABLE_DNS="true" \
|
||||
NB_ENTRYPOINT_SERVICE_TIMEOUT="5" \
|
||||
NB_ENTRYPOINT_LOGIN_TIMEOUT="1"
|
||||
NB_ENTRYPOINT_SERVICE_TIMEOUT="30"
|
||||
|
||||
ENTRYPOINT [ "/usr/local/bin/netbird-entrypoint.sh" ]
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"os"
|
||||
"slices"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"golang.org/x/exp/maps"
|
||||
|
||||
@@ -15,6 +16,7 @@ import (
|
||||
|
||||
"github.com/netbirdio/netbird/client/iface/device"
|
||||
"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/listener"
|
||||
"github.com/netbirdio/netbird/client/internal/peer"
|
||||
@@ -26,6 +28,7 @@ import (
|
||||
"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
|
||||
@@ -68,7 +71,30 @@ type Client struct {
|
||||
uiVersion string
|
||||
networkChangeListener listener.NetworkChangeListener
|
||||
|
||||
stateMu sync.RWMutex
|
||||
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
|
||||
@@ -93,6 +119,7 @@ func (c *Client) Run(platformFiles PlatformFiles, urlOpener URLOpener, isAndroid
|
||||
|
||||
cfgFile := platformFiles.ConfigurationFilePath()
|
||||
stateFile := platformFiles.StateFilePath()
|
||||
cacheDir := platformFiles.CacheDir()
|
||||
|
||||
log.Infof("Starting client with config: %s, state: %s", cfgFile, stateFile)
|
||||
|
||||
@@ -124,8 +151,9 @@ func (c *Client) Run(platformFiles PlatformFiles, urlOpener URLOpener, isAndroid
|
||||
|
||||
// todo do not throw error in case of cancelled context
|
||||
ctx = internal.CtxInitState(ctx)
|
||||
c.connectClient = internal.NewConnectClient(ctx, cfg, c.recorder, false)
|
||||
return c.connectClient.RunOnAndroid(c.tunAdapter, c.iFaceDiscover, c.networkChangeListener, slices.Clone(dns.items), dnsReadyListener, stateFile)
|
||||
connectClient := internal.NewConnectClient(ctx, cfg, c.recorder)
|
||||
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).
|
||||
@@ -135,6 +163,7 @@ func (c *Client) RunWithoutLogin(platformFiles PlatformFiles, dns *DNSList, dnsR
|
||||
|
||||
cfgFile := platformFiles.ConfigurationFilePath()
|
||||
stateFile := platformFiles.StateFilePath()
|
||||
cacheDir := platformFiles.CacheDir()
|
||||
|
||||
log.Infof("Starting client without login with config: %s, state: %s", cfgFile, stateFile)
|
||||
|
||||
@@ -157,8 +186,9 @@ func (c *Client) RunWithoutLogin(platformFiles PlatformFiles, dns *DNSList, dnsR
|
||||
|
||||
// todo do not throw error in case of cancelled context
|
||||
ctx = internal.CtxInitState(ctx)
|
||||
c.connectClient = internal.NewConnectClient(ctx, cfg, c.recorder, false)
|
||||
return c.connectClient.RunOnAndroid(c.tunAdapter, c.iFaceDiscover, c.networkChangeListener, slices.Clone(dns.items), dnsReadyListener, stateFile)
|
||||
connectClient := internal.NewConnectClient(ctx, cfg, c.recorder)
|
||||
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
|
||||
@@ -173,11 +203,12 @@ func (c *Client) Stop() {
|
||||
}
|
||||
|
||||
func (c *Client) RenewTun(fd int) error {
|
||||
if c.connectClient == nil {
|
||||
cc := c.getConnectClient()
|
||||
if cc == nil {
|
||||
return fmt.Errorf("engine not running")
|
||||
}
|
||||
|
||||
e := c.connectClient.Engine()
|
||||
e := cc.Engine()
|
||||
if e == nil {
|
||||
return fmt.Errorf("engine not initialized")
|
||||
}
|
||||
@@ -185,6 +216,73 @@ func (c *Client) RenewTun(fd int) error {
|
||||
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
|
||||
func (c *Client) SetTraceLogLevel() {
|
||||
log.SetLevel(log.TraceLevel)
|
||||
@@ -205,7 +303,7 @@ func (c *Client) PeersList() *PeerInfoArray {
|
||||
pi := PeerInfo{
|
||||
p.IP,
|
||||
p.FQDN,
|
||||
p.ConnStatus.String(),
|
||||
int(p.ConnStatus),
|
||||
PeerRoutes{routes: maps.Keys(p.GetRoutes())},
|
||||
}
|
||||
peerInfos[n] = pi
|
||||
@@ -214,12 +312,13 @@ func (c *Client) PeersList() *PeerInfoArray {
|
||||
}
|
||||
|
||||
func (c *Client) Networks() *NetworkArray {
|
||||
if c.connectClient == nil {
|
||||
cc := c.getConnectClient()
|
||||
if cc == nil {
|
||||
log.Error("not connected")
|
||||
return nil
|
||||
}
|
||||
|
||||
engine := c.connectClient.Engine()
|
||||
engine := cc.Engine()
|
||||
if engine == nil {
|
||||
log.Error("could not get engine")
|
||||
return nil
|
||||
@@ -300,7 +399,7 @@ func (c *Client) toggleRoute(command routeCommand) error {
|
||||
}
|
||||
|
||||
func (c *Client) getRouteManager() (routemanager.Manager, error) {
|
||||
client := c.connectClient
|
||||
client := c.getConnectClient()
|
||||
if client == nil {
|
||||
return nil, fmt.Errorf("not connected")
|
||||
}
|
||||
|
||||
@@ -2,11 +2,20 @@
|
||||
|
||||
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
|
||||
type PeerInfo struct {
|
||||
IP string
|
||||
FQDN string
|
||||
ConnStatus string // Todo replace to enum
|
||||
ConnStatus int
|
||||
Routes PeerRoutes
|
||||
}
|
||||
|
||||
|
||||
@@ -7,4 +7,5 @@ package android
|
||||
type PlatformFiles interface {
|
||||
ConfigurationFilePath() string
|
||||
StateFilePath() string
|
||||
CacheDir() string
|
||||
}
|
||||
|
||||
@@ -181,10 +181,11 @@ func runForDuration(cmd *cobra.Command, args []string) error {
|
||||
|
||||
if stateWasDown {
|
||||
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
|
||||
@@ -198,10 +199,13 @@ func runForDuration(cmd *cobra.Command, args []string) error {
|
||||
cmd.Println("Log level set to trace.")
|
||||
}
|
||||
|
||||
needsRestoreUp := false
|
||||
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)
|
||||
|
||||
@@ -209,13 +213,15 @@ func runForDuration(cmd *cobra.Command, args []string) error {
|
||||
if _, err := client.SetSyncResponsePersistence(cmd.Context(), &proto.SetSyncResponsePersistenceRequest{
|
||||
Enabled: true,
|
||||
}); 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 {
|
||||
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)
|
||||
|
||||
@@ -261,18 +267,28 @@ func runForDuration(cmd *cobra.Command, args []string) error {
|
||||
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 _, 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 _, 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())
|
||||
|
||||
@@ -14,7 +14,9 @@ import (
|
||||
|
||||
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"
|
||||
)
|
||||
@@ -22,20 +24,24 @@ import (
|
||||
var pinRegexp = regexp.MustCompile(`^\d{6}$`)
|
||||
|
||||
var (
|
||||
exposePin string
|
||||
exposePassword string
|
||||
exposeUserGroups []string
|
||||
exposeDomain string
|
||||
exposeNamePrefix string
|
||||
exposeProtocol string
|
||||
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",
|
||||
RunE: exposeFn,
|
||||
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() {
|
||||
@@ -44,7 +50,52 @@ func init() {
|
||||
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 is supported (e.g. --protocol http)")
|
||||
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) {
|
||||
@@ -57,7 +108,15 @@ func validateExposeFlags(cmd *cobra.Command, portStr string) (uint64, error) {
|
||||
}
|
||||
|
||||
if !isProtocolValid(exposeProtocol) {
|
||||
return 0, fmt.Errorf("unsupported protocol %q: only 'http' or 'https' are supported", 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) {
|
||||
@@ -76,7 +135,12 @@ func validateExposeFlags(cmd *cobra.Command, portStr string) (uint64, error) {
|
||||
}
|
||||
|
||||
func isProtocolValid(exposeProtocol string) bool {
|
||||
return strings.ToLower(exposeProtocol) == "http" || strings.ToLower(exposeProtocol) == "https"
|
||||
switch strings.ToLower(exposeProtocol) {
|
||||
case "http", "https", "tcp", "udp", "tls":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func exposeFn(cmd *cobra.Command, args []string) error {
|
||||
@@ -123,7 +187,7 @@ func exposeFn(cmd *cobra.Command, args []string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
stream, err := client.ExposeService(ctx, &proto.ExposeServiceRequest{
|
||||
req := &proto.ExposeServiceRequest{
|
||||
Port: uint32(port),
|
||||
Protocol: protocol,
|
||||
Pin: exposePin,
|
||||
@@ -131,9 +195,14 @@ func exposeFn(cmd *cobra.Command, args []string) error {
|
||||
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: %w", err)
|
||||
return fmt.Errorf("expose service: %v", status.Convert(err).Message())
|
||||
}
|
||||
|
||||
if err := handleExposeReady(cmd, stream, port); err != nil {
|
||||
@@ -144,36 +213,60 @@ func exposeFn(cmd *cobra.Command, args []string) error {
|
||||
}
|
||||
|
||||
func toExposeProtocol(exposeProtocol string) (proto.ExposeProtocol, error) {
|
||||
switch strings.ToLower(exposeProtocol) {
|
||||
case "http":
|
||||
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 "https":
|
||||
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("unsupported protocol %q: only 'http' or 'https' are supported", exposeProtocol)
|
||||
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: %w", err)
|
||||
return fmt.Errorf("receive expose event: %v", status.Convert(err).Message())
|
||||
}
|
||||
|
||||
switch e := event.Event.(type) {
|
||||
case *proto.ExposeServiceEvent_Ready:
|
||||
cmd.Println("Service exposed successfully!")
|
||||
cmd.Printf(" Name: %s\n", e.Ready.ServiceName)
|
||||
cmd.Printf(" URL: %s\n", e.Ready.ServiceUrl)
|
||||
cmd.Printf(" Domain: %s\n", e.Ready.Domain)
|
||||
cmd.Printf(" Protocol: %s\n", exposeProtocol)
|
||||
cmd.Printf(" Port: %d\n", port)
|
||||
cmd.Println()
|
||||
cmd.Println("Press Ctrl+C to stop exposing.")
|
||||
return nil
|
||||
default:
|
||||
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 {
|
||||
|
||||
@@ -75,6 +75,7 @@ var (
|
||||
mtu uint16
|
||||
profilesDisabled bool
|
||||
updateSettingsDisabled bool
|
||||
networksDisabled bool
|
||||
|
||||
rootCmd = &cobra.Command{
|
||||
Use: "netbird",
|
||||
|
||||
@@ -41,13 +41,16 @@ func init() {
|
||||
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(&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(&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")
|
||||
serviceEnvDesc := `Sets extra environment variables for the service. ` +
|
||||
`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`
|
||||
|
||||
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, networksDisabled)
|
||||
if err := serverInstance.Start(); err != nil {
|
||||
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
|
||||
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)
|
||||
|
||||
cmd.SetOut(cmd.OutOrStdout())
|
||||
|
||||
@@ -59,6 +59,10 @@ func buildServiceArguments() []string {
|
||||
args = append(args, "--disable-update-settings")
|
||||
}
|
||||
|
||||
if networksDisabled {
|
||||
args = append(args, "--disable-networks")
|
||||
}
|
||||
|
||||
return args
|
||||
}
|
||||
|
||||
@@ -119,6 +123,10 @@ var installCmd = &cobra.Command{
|
||||
return err
|
||||
}
|
||||
|
||||
if err := loadAndApplyServiceParams(cmd); err != nil {
|
||||
cmd.PrintErrf("Warning: failed to load saved service params: %v\n", err)
|
||||
}
|
||||
|
||||
svcConfig, err := createServiceConfigForInstall()
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -136,6 +144,10 @@ var installCmd = &cobra.Command{
|
||||
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")
|
||||
return nil
|
||||
},
|
||||
@@ -187,6 +199,10 @@ This command will temporarily stop the service, update its configuration, and re
|
||||
return err
|
||||
}
|
||||
|
||||
if err := loadAndApplyServiceParams(cmd); err != nil {
|
||||
cmd.PrintErrf("Warning: failed to load saved service params: %v\n", err)
|
||||
}
|
||||
|
||||
wasRunning, err := isServiceRunning()
|
||||
if err != nil && !errors.Is(err, ErrGetServiceStatus) {
|
||||
return fmt.Errorf("check service status: %w", err)
|
||||
@@ -222,6 +238,10 @@ This command will temporarily stop the service, update its configuration, and re
|
||||
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 {
|
||||
cmd.Println("Starting NetBird service...")
|
||||
if err := s.Start(); err != nil {
|
||||
|
||||
218
client/cmd/service_params.go
Normal file
@@ -0,0 +1,218 @@
|
||||
//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"`
|
||||
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,
|
||||
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("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
|
||||
}
|
||||
559
client/cmd/service_params_test.go
Normal file
@@ -0,0 +1,559 @@
|
||||
//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",
|
||||
"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"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/signal"
|
||||
"runtime"
|
||||
"syscall"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -13,6 +15,22 @@ import (
|
||||
"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 (
|
||||
serviceStartTimeout = 10 * time.Second
|
||||
serviceStopTimeout = 5 * time.Second
|
||||
@@ -79,6 +97,34 @@ func TestServiceLifecycle(t *testing.T) {
|
||||
logLevel = "info"
|
||||
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()
|
||||
|
||||
t.Run("Install", func(t *testing.T) {
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/updatemanager/reposign"
|
||||
"github.com/netbirdio/netbird/client/internal/updater/reposign"
|
||||
)
|
||||
|
||||
var (
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/updatemanager/reposign"
|
||||
"github.com/netbirdio/netbird/client/internal/updater/reposign"
|
||||
)
|
||||
|
||||
const (
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/updatemanager/reposign"
|
||||
"github.com/netbirdio/netbird/client/internal/updater/reposign"
|
||||
)
|
||||
|
||||
const (
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/updatemanager/reposign"
|
||||
"github.com/netbirdio/netbird/client/internal/updater/reposign"
|
||||
)
|
||||
|
||||
var (
|
||||
|
||||
@@ -28,6 +28,7 @@ var (
|
||||
ipsFilterMap map[string]struct{}
|
||||
prefixNamesFilterMap map[string]struct{}
|
||||
connectionTypeFilter string
|
||||
checkFlag string
|
||||
)
|
||||
|
||||
var statusCmd = &cobra.Command{
|
||||
@@ -49,6 +50,7 @@ func init() {
|
||||
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(&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 {
|
||||
@@ -56,6 +58,10 @@ func statusFunc(cmd *cobra.Command, args []string) error {
|
||||
|
||||
cmd.SetOut(cmd.OutOrStdout())
|
||||
|
||||
if checkFlag != "" {
|
||||
return runHealthCheck(cmd)
|
||||
}
|
||||
|
||||
err := parseFilters()
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -68,15 +74,17 @@ func statusFunc(cmd *cobra.Command, args []string) error {
|
||||
|
||||
ctx := internal.CtxInitState(cmd.Context())
|
||||
|
||||
resp, err := getStatus(ctx, false)
|
||||
resp, err := getStatus(ctx, true, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
status := resp.GetStatus()
|
||||
|
||||
if status == string(internal.StatusNeedsLogin) || status == string(internal.StatusLoginFailed) ||
|
||||
status == string(internal.StatusSessionExpired) {
|
||||
needsAuth := status == string(internal.StatusNeedsLogin) || status == string(internal.StatusLoginFailed) ||
|
||||
status == string(internal.StatusSessionExpired)
|
||||
|
||||
if needsAuth && !jsonFlag && !yamlFlag {
|
||||
cmd.Printf("Daemon status: %s\n\n"+
|
||||
"Run UP command to log in with SSO (interactive login):\n\n"+
|
||||
" netbird up \n\n"+
|
||||
@@ -99,7 +107,17 @@ func statusFunc(cmd *cobra.Command, args []string) error {
|
||||
profName = activeProf.Name
|
||||
}
|
||||
|
||||
var outputInformationHolder = nbstatus.ConvertToStatusOutputOverview(resp.GetFullStatus(), anonymizeFlag, resp.GetDaemonVersion(), 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
|
||||
switch {
|
||||
case detailFlag:
|
||||
@@ -121,7 +139,7 @@ func statusFunc(cmd *cobra.Command, args []string) error {
|
||||
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)
|
||||
if err != nil {
|
||||
//nolint
|
||||
@@ -131,7 +149,7 @@ func getStatus(ctx context.Context, shouldRunProbes bool) (*proto.StatusResponse
|
||||
}
|
||||
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 {
|
||||
return nil, fmt.Errorf("status failed: %v", status.Convert(err).Message())
|
||||
}
|
||||
@@ -185,6 +203,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 {
|
||||
ip, _, err := net.ParseCIDR(interfaceIP)
|
||||
if err != nil {
|
||||
|
||||
@@ -13,6 +13,8 @@ import (
|
||||
|
||||
"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"
|
||||
@@ -100,9 +102,16 @@ func startManagement(t *testing.T, config *config.Config, testFile string) (*grp
|
||||
|
||||
jobManager := job.NewJobManager(nil, store, peersmanager)
|
||||
|
||||
iv, _ := integrations.NewIntegratedValidator(context.Background(), peersmanager, settingsManagerMock, eventStore)
|
||||
ctx := context.Background()
|
||||
|
||||
metrics, err := telemetry.NewDefaultAppMetrics(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)
|
||||
|
||||
settingsMockManager := settings.NewMockManager(ctrl)
|
||||
@@ -113,12 +122,11 @@ func startManagement(t *testing.T, config *config.Config, testFile string) (*grp
|
||||
Return(&types.Settings{}, nil).
|
||||
AnyTimes()
|
||||
|
||||
ctx := context.Background()
|
||||
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(context.Background(), config, store, networkMapController, jobManager, nil, "", eventStore, nil, false, iv, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManagerMock, false)
|
||||
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 {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -152,7 +160,7 @@ func startClientDaemon(
|
||||
s := grpc.NewServer()
|
||||
|
||||
server := client.New(ctx,
|
||||
"", "", false, false)
|
||||
"", "", false, false, false)
|
||||
if err := server.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -197,7 +197,7 @@ func runInForegroundMode(ctx context.Context, cmd *cobra.Command, activeProf *pr
|
||||
r := peer.NewRecorder(config.ManagementURL.String())
|
||||
r.GetFullStatus()
|
||||
|
||||
connectClient := internal.NewConnectClient(ctx, config, r, false)
|
||||
connectClient := internal.NewConnectClient(ctx, config, r)
|
||||
SetupDebugHandler(ctx, config, r, connectClient, "")
|
||||
|
||||
return connectClient.Run(nil, util.FindFirstLogPath(logFiles))
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
log "github.com/sirupsen/logrus"
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/updatemanager/installer"
|
||||
"github.com/netbirdio/netbird/client/internal/updater/installer"
|
||||
"github.com/netbirdio/netbird/util"
|
||||
)
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"github.com/sirupsen/logrus"
|
||||
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/internal"
|
||||
"github.com/netbirdio/netbird/client/internal/auth"
|
||||
@@ -21,6 +22,7 @@ import (
|
||||
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
||||
sshcommon "github.com/netbirdio/netbird/client/ssh"
|
||||
"github.com/netbirdio/netbird/client/system"
|
||||
"github.com/netbirdio/netbird/shared/management/domain"
|
||||
mgmProto "github.com/netbirdio/netbird/shared/management/proto"
|
||||
)
|
||||
|
||||
@@ -31,14 +33,14 @@ var (
|
||||
ErrConfigNotInitialized = errors.New("config not initialized")
|
||||
)
|
||||
|
||||
// PeerConnStatus is a peer's connection status.
|
||||
type PeerConnStatus = peer.ConnStatus
|
||||
|
||||
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.
|
||||
type Client struct {
|
||||
deviceName string
|
||||
@@ -81,6 +83,14 @@ type Options struct {
|
||||
BlockInbound bool
|
||||
// WireguardPort is the port for the WireGuard interface. Use 0 for a random port.
|
||||
WireguardPort *int
|
||||
// MTU is the MTU for the WireGuard 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
|
||||
@@ -112,6 +122,12 @@ func New(opts Options) (*Client, error) {
|
||||
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 {
|
||||
logrus.SetOutput(opts.LogOutput)
|
||||
}
|
||||
@@ -140,9 +156,14 @@ 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
|
||||
var config *profilemanager.Config
|
||||
var err error
|
||||
input := profilemanager.ConfigInput{
|
||||
ConfigPath: opts.ConfigPath,
|
||||
ManagementURL: opts.ManagementURL,
|
||||
@@ -151,6 +172,8 @@ func New(opts Options) (*Client, error) {
|
||||
DisableClientRoutes: &opts.DisableClientRoutes,
|
||||
BlockInbound: &opts.BlockInbound,
|
||||
WireguardPort: opts.WireguardPort,
|
||||
MTU: opts.MTU,
|
||||
DNSLabels: parsedLabels,
|
||||
}
|
||||
if opts.ConfigPath != "" {
|
||||
config, err = profilemanager.UpdateOrCreateConfig(input)
|
||||
@@ -202,7 +225,7 @@ func (c *Client) Start(startCtx context.Context) error {
|
||||
if err, _ := authClient.Login(ctx, c.setupKey, c.jwtToken); err != nil {
|
||||
return fmt.Errorf("login: %w", err)
|
||||
}
|
||||
client := internal.NewConnectClient(ctx, c.config, c.recorder, false)
|
||||
client := internal.NewConnectClient(ctx, c.config, c.recorder)
|
||||
client.SetSyncResponsePersistence(true)
|
||||
|
||||
// either startup error (permanent backoff err) or nil err (successful engine up)
|
||||
@@ -352,6 +375,32 @@ 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()
|
||||
|
||||
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"
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
|
||||
"github.com/coreos/go-iptables/iptables"
|
||||
"github.com/google/nftables"
|
||||
@@ -35,20 +36,34 @@ const SKIP_NFTABLES_ENV = "NB_SKIP_NFTABLES_CHECK"
|
||||
type FWType int
|
||||
|
||||
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
|
||||
// in any case, because we need to allow netbird interface traffic
|
||||
// so we use AllowNetbird traffic from these firewall managers
|
||||
// for the userspace packet filtering firewall
|
||||
// We run in userspace mode and force userspace firewall was requested. We don't attempt native firewall.
|
||||
if iface.IsUserspaceBind() && forceUserspaceFirewall() {
|
||||
log.Info("forcing userspace 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)
|
||||
|
||||
// Kernel cannot fall back to anything else, need to return error
|
||||
if !iface.IsUserspaceBind() {
|
||||
return fm, err
|
||||
}
|
||||
|
||||
// Fall back to the userspace packet filter if native is unavailable
|
||||
if err != nil {
|
||||
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) {
|
||||
@@ -160,3 +175,17 @@ func isIptablesClientAvailable(client *iptables.IPTables) bool {
|
||||
_, err := client.ListChains("filter")
|
||||
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
@@ -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
@@ -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
@@ -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
@@ -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"
|
||||
)
|
||||
|
||||
// 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
|
||||
type IFaceMapper interface {
|
||||
Name() string
|
||||
|
||||
@@ -21,6 +21,10 @@ const (
|
||||
|
||||
// rules chains contains the effective ACL rules
|
||||
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
|
||||
@@ -274,6 +278,12 @@ func (m *aclManager) cleanChains() error {
|
||||
}
|
||||
}
|
||||
|
||||
for _, rule := range m.entries[mangleFwdKey] {
|
||||
if err := m.iptablesClient.DeleteIfExists(tableMangle, chainFORWARD, rule...); err != nil {
|
||||
log.Errorf("failed to delete mangle FORWARD guard rule: %v, %s", rule, err)
|
||||
}
|
||||
}
|
||||
|
||||
for _, ipsetName := range m.ipsetStore.ipsetNames() {
|
||||
if err := m.flushIPSet(ipsetName); err != nil {
|
||||
if errors.Is(err, ipset.ErrSetNotExist) {
|
||||
@@ -303,6 +313,10 @@ func (m *aclManager) createDefaultChains() error {
|
||||
}
|
||||
|
||||
for chainName, rules := range m.entries {
|
||||
// mangle FORWARD guard rules are handled separately below
|
||||
if chainName == mangleFwdKey {
|
||||
continue
|
||||
}
|
||||
for _, rule := range rules {
|
||||
if err := m.iptablesClient.InsertUnique(tableName, chainName, 1, rule...); err != nil {
|
||||
log.Debugf("failed to create input chain jump rule: %s", err)
|
||||
@@ -322,6 +336,13 @@ func (m *aclManager) createDefaultChains() error {
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
@@ -343,6 +364,22 @@ func (m *aclManager) seedInitialEntries() {
|
||||
|
||||
m.appendToEntries("FORWARD", []string{"-o", m.wgIface.Name(), "-j", chainRTFWDOUT})
|
||||
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() {
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
nberrors "github.com/netbirdio/netbird/client/errors"
|
||||
"github.com/netbirdio/netbird/client/firewall/firewalld"
|
||||
firewall "github.com/netbirdio/netbird/client/firewall/manager"
|
||||
"github.com/netbirdio/netbird/client/iface/wgaddr"
|
||||
"github.com/netbirdio/netbird/client/internal/statemanager"
|
||||
@@ -23,16 +24,16 @@ type Manager struct {
|
||||
|
||||
wgIface iFaceMapper
|
||||
|
||||
ipv4Client *iptables.IPTables
|
||||
aclMgr *aclManager
|
||||
router *router
|
||||
ipv4Client *iptables.IPTables
|
||||
aclMgr *aclManager
|
||||
router *router
|
||||
rawSupported bool
|
||||
}
|
||||
|
||||
// iFaceMapper defines subset methods of interface required for manager
|
||||
type iFaceMapper interface {
|
||||
Name() string
|
||||
Address() wgaddr.Address
|
||||
IsUserspaceBind() bool
|
||||
}
|
||||
|
||||
// Create iptables firewall manager
|
||||
@@ -63,10 +64,9 @@ func Create(wgIface iFaceMapper, mtu uint16) (*Manager, error) {
|
||||
func (m *Manager) Init(stateManager *statemanager.Manager) error {
|
||||
state := &ShutdownState{
|
||||
InterfaceState: &InterfaceState{
|
||||
NameStr: m.wgIface.Name(),
|
||||
WGAddress: m.wgIface.Address(),
|
||||
UserspaceBind: m.wgIface.IsUserspaceBind(),
|
||||
MTU: m.router.mtu,
|
||||
NameStr: m.wgIface.Name(),
|
||||
WGAddress: m.wgIface.Address(),
|
||||
MTU: m.router.mtu,
|
||||
},
|
||||
}
|
||||
stateManager.RegisterState(state)
|
||||
@@ -84,7 +84,13 @@ func (m *Manager) Init(stateManager *statemanager.Manager) error {
|
||||
}
|
||||
|
||||
if err := m.initNoTrackChain(); err != nil {
|
||||
return fmt.Errorf("init notrack chain: %w", err)
|
||||
log.Warnf("raw table not available, notrack rules will be disabled: %v", 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
|
||||
@@ -192,6 +198,12 @@ func (m *Manager) Close(stateManager *statemanager.Manager) error {
|
||||
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
|
||||
if merr == nil {
|
||||
if err := stateManager.DeleteState(&ShutdownState{}); err != nil {
|
||||
@@ -202,12 +214,10 @@ func (m *Manager) Close(stateManager *statemanager.Manager) error {
|
||||
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 {
|
||||
if !m.wgIface.IsUserspaceBind() {
|
||||
return nil
|
||||
}
|
||||
|
||||
_, err := m.AddPeerFiltering(
|
||||
nil,
|
||||
net.IP{0, 0, 0, 0},
|
||||
@@ -220,6 +230,11 @@ func (m *Manager) AllowNetbird() error {
|
||||
if err != nil {
|
||||
return fmt.Errorf("allow netbird interface traffic: %w", err)
|
||||
}
|
||||
|
||||
if err := firewalld.TrustInterface(m.wgIface.Name()); err != nil {
|
||||
log.Warnf("failed to trust interface in firewalld: %v", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -285,6 +300,22 @@ func (m *Manager) RemoveInboundDNAT(localAddr netip.Addr, protocol firewall.Prot
|
||||
return m.router.RemoveInboundDNAT(localAddr, protocol, sourcePort, targetPort)
|
||||
}
|
||||
|
||||
// AddOutputDNAT adds an OUTPUT chain DNAT rule for locally-generated traffic.
|
||||
func (m *Manager) AddOutputDNAT(localAddr netip.Addr, protocol firewall.Protocol, sourcePort, targetPort uint16) error {
|
||||
m.mutex.Lock()
|
||||
defer m.mutex.Unlock()
|
||||
|
||||
return m.router.AddOutputDNAT(localAddr, protocol, sourcePort, targetPort)
|
||||
}
|
||||
|
||||
// RemoveOutputDNAT removes an OUTPUT chain DNAT rule.
|
||||
func (m *Manager) RemoveOutputDNAT(localAddr netip.Addr, protocol firewall.Protocol, sourcePort, targetPort uint16) error {
|
||||
m.mutex.Lock()
|
||||
defer m.mutex.Unlock()
|
||||
|
||||
return m.router.RemoveOutputDNAT(localAddr, protocol, sourcePort, targetPort)
|
||||
}
|
||||
|
||||
const (
|
||||
chainNameRaw = "NETBIRD-RAW"
|
||||
chainOUTPUT = "OUTPUT"
|
||||
@@ -318,6 +349,10 @@ 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)
|
||||
|
||||
@@ -375,12 +410,16 @@ func (m *Manager) initNoTrackChain() error {
|
||||
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 {
|
||||
@@ -401,6 +440,7 @@ func (m *Manager) cleanupNoTrackChain() error {
|
||||
return fmt.Errorf("clear and delete chain: %w", err)
|
||||
}
|
||||
|
||||
m.rawSupported = false
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -47,8 +47,6 @@ func (i *iFaceMock) Address() wgaddr.Address {
|
||||
panic("AddressFunc is not set")
|
||||
}
|
||||
|
||||
func (i *iFaceMock) IsUserspaceBind() bool { return false }
|
||||
|
||||
func TestIptablesManager(t *testing.T) {
|
||||
ipv4Client, err := iptables.NewWithProtocol(iptables.ProtocolIPv4)
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -36,6 +36,7 @@ const (
|
||||
chainRTFWDOUT = "NETBIRD-RT-FWD-OUT"
|
||||
chainRTPRE = "NETBIRD-RT-PRE"
|
||||
chainRTRDR = "NETBIRD-RT-RDR"
|
||||
chainNATOutput = "NETBIRD-NAT-OUTPUT"
|
||||
chainRTMSSCLAMP = "NETBIRD-RT-MSSCLAMP"
|
||||
routingFinalForwardJump = "ACCEPT"
|
||||
routingFinalNatJump = "MASQUERADE"
|
||||
@@ -43,6 +44,7 @@ const (
|
||||
jumpManglePre = "jump-mangle-pre"
|
||||
jumpNatPre = "jump-nat-pre"
|
||||
jumpNatPost = "jump-nat-post"
|
||||
jumpNatOutput = "jump-nat-output"
|
||||
jumpMSSClamp = "jump-mss-clamp"
|
||||
markManglePre = "mark-mangle-pre"
|
||||
markManglePost = "mark-mangle-post"
|
||||
@@ -387,6 +389,14 @@ func (r *router) cleanUpDefaultForwardRules() error {
|
||||
}
|
||||
|
||||
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".
|
||||
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 {
|
||||
chain string
|
||||
table string
|
||||
@@ -396,6 +406,7 @@ func (r *router) cleanUpDefaultForwardRules() error {
|
||||
{chainRTPRE, tableMangle},
|
||||
{chainRTNAT, tableNat},
|
||||
{chainRTRDR, tableNat},
|
||||
{chainNATOutput, tableNat},
|
||||
{chainRTMSSCLAMP, tableMangle},
|
||||
} {
|
||||
ok, err := r.iptablesClient.ChainExists(chainInfo.table, chainInfo.chain)
|
||||
@@ -970,6 +981,81 @@ func (r *router) RemoveInboundDNAT(localAddr netip.Addr, protocol firewall.Proto
|
||||
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, sourcePort, targetPort uint16) error {
|
||||
ruleID := fmt.Sprintf("output-dnat-%s-%s-%d-%d", localAddr.String(), protocol, sourcePort, targetPort)
|
||||
|
||||
if _, exists := r.rules[ruleID]; exists {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := r.ensureNATOutputChain(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
dnatRule := []string{
|
||||
"-p", strings.ToLower(string(protocol)),
|
||||
"--dport", strconv.Itoa(int(sourcePort)),
|
||||
"-d", localAddr.String(),
|
||||
"-j", "DNAT",
|
||||
"--to-destination", ":" + strconv.Itoa(int(targetPort)),
|
||||
}
|
||||
|
||||
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, sourcePort, targetPort uint16) error {
|
||||
ruleID := fmt.Sprintf("output-dnat-%s-%s-%d-%d", localAddr.String(), protocol, sourcePort, targetPort)
|
||||
|
||||
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 {
|
||||
if port == nil {
|
||||
return nil
|
||||
|
||||
@@ -9,10 +9,9 @@ import (
|
||||
)
|
||||
|
||||
type InterfaceState struct {
|
||||
NameStr string `json:"name"`
|
||||
WGAddress wgaddr.Address `json:"wg_address"`
|
||||
UserspaceBind bool `json:"userspace_bind"`
|
||||
MTU uint16 `json:"mtu"`
|
||||
NameStr string `json:"name"`
|
||||
WGAddress wgaddr.Address `json:"wg_address"`
|
||||
MTU uint16 `json:"mtu"`
|
||||
}
|
||||
|
||||
func (i *InterfaceState) Name() string {
|
||||
@@ -23,10 +22,6 @@ func (i *InterfaceState) Address() wgaddr.Address {
|
||||
return i.WGAddress
|
||||
}
|
||||
|
||||
func (i *InterfaceState) IsUserspaceBind() bool {
|
||||
return i.UserspaceBind
|
||||
}
|
||||
|
||||
type ShutdownState struct {
|
||||
sync.Mutex
|
||||
|
||||
|
||||
@@ -169,6 +169,14 @@ type Manager interface {
|
||||
// RemoveInboundDNAT removes inbound DNAT rule
|
||||
RemoveInboundDNAT(localAddr netip.Addr, protocol Protocol, sourcePort, targetPort uint16) error
|
||||
|
||||
// AddOutputDNAT adds an OUTPUT chain DNAT rule for locally-generated traffic.
|
||||
// localAddr must be IPv4; the underlying iptables/nftables backends are IPv4-only.
|
||||
AddOutputDNAT(localAddr netip.Addr, protocol Protocol, sourcePort, targetPort uint16) error
|
||||
|
||||
// RemoveOutputDNAT removes an OUTPUT chain DNAT rule.
|
||||
// localAddr must be IPv4; the underlying iptables/nftables backends are IPv4-only.
|
||||
RemoveOutputDNAT(localAddr netip.Addr, protocol Protocol, sourcePort, targetPort 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
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
log "github.com/sirupsen/logrus"
|
||||
"golang.org/x/sys/unix"
|
||||
|
||||
"github.com/netbirdio/netbird/client/firewall/firewalld"
|
||||
firewall "github.com/netbirdio/netbird/client/firewall/manager"
|
||||
"github.com/netbirdio/netbird/client/iface/wgaddr"
|
||||
"github.com/netbirdio/netbird/client/internal/statemanager"
|
||||
@@ -40,7 +41,6 @@ func getTableName() string {
|
||||
type iFaceMapper interface {
|
||||
Name() string
|
||||
Address() wgaddr.Address
|
||||
IsUserspaceBind() bool
|
||||
}
|
||||
|
||||
// Manager of iptables firewall
|
||||
@@ -95,7 +95,7 @@ func (m *Manager) Init(stateManager *statemanager.Manager) error {
|
||||
}
|
||||
|
||||
if err := m.initNoTrackChains(workTable); err != nil {
|
||||
return fmt.Errorf("init notrack chains: %w", err)
|
||||
log.Warnf("raw priority chains not available, notrack rules will be disabled: %v", err)
|
||||
}
|
||||
|
||||
stateManager.RegisterState(&ShutdownState{})
|
||||
@@ -106,10 +106,9 @@ func (m *Manager) Init(stateManager *statemanager.Manager) error {
|
||||
// cleanup using Close() without needing to store specific rules.
|
||||
if err := stateManager.UpdateState(&ShutdownState{
|
||||
InterfaceState: &InterfaceState{
|
||||
NameStr: m.wgIface.Name(),
|
||||
WGAddress: m.wgIface.Address(),
|
||||
UserspaceBind: m.wgIface.IsUserspaceBind(),
|
||||
MTU: m.router.mtu,
|
||||
NameStr: m.wgIface.Name(),
|
||||
WGAddress: m.wgIface.Address(),
|
||||
MTU: m.router.mtu,
|
||||
},
|
||||
}); err != nil {
|
||||
log.Errorf("failed to update state: %v", err)
|
||||
@@ -205,12 +204,10 @@ func (m *Manager) RemoveNatRule(pair firewall.RouterPair) error {
|
||||
return m.router.RemoveNatRule(pair)
|
||||
}
|
||||
|
||||
// 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 {
|
||||
if !m.wgIface.IsUserspaceBind() {
|
||||
return nil
|
||||
}
|
||||
|
||||
m.mutex.Lock()
|
||||
defer m.mutex.Unlock()
|
||||
|
||||
@@ -221,6 +218,10 @@ func (m *Manager) AllowNetbird() error {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -346,6 +347,22 @@ func (m *Manager) RemoveInboundDNAT(localAddr netip.Addr, protocol firewall.Prot
|
||||
return m.router.RemoveInboundDNAT(localAddr, protocol, sourcePort, targetPort)
|
||||
}
|
||||
|
||||
// AddOutputDNAT adds an OUTPUT chain DNAT rule for locally-generated traffic.
|
||||
func (m *Manager) AddOutputDNAT(localAddr netip.Addr, protocol firewall.Protocol, sourcePort, targetPort uint16) error {
|
||||
m.mutex.Lock()
|
||||
defer m.mutex.Unlock()
|
||||
|
||||
return m.router.AddOutputDNAT(localAddr, protocol, sourcePort, targetPort)
|
||||
}
|
||||
|
||||
// RemoveOutputDNAT removes an OUTPUT chain DNAT rule.
|
||||
func (m *Manager) RemoveOutputDNAT(localAddr netip.Addr, protocol firewall.Protocol, sourcePort, targetPort uint16) error {
|
||||
m.mutex.Lock()
|
||||
defer m.mutex.Unlock()
|
||||
|
||||
return m.router.RemoveOutputDNAT(localAddr, protocol, sourcePort, targetPort)
|
||||
}
|
||||
|
||||
const (
|
||||
chainNameRawOutput = "netbird-raw-out"
|
||||
chainNameRawPrerouting = "netbird-raw-pre"
|
||||
|
||||
@@ -52,8 +52,6 @@ func (i *iFaceMock) Address() wgaddr.Address {
|
||||
panic("AddressFunc is not set")
|
||||
}
|
||||
|
||||
func (i *iFaceMock) IsUserspaceBind() bool { return false }
|
||||
|
||||
func TestNftablesManager(t *testing.T) {
|
||||
|
||||
// just check on the local interface
|
||||
|
||||
@@ -19,6 +19,7 @@ import (
|
||||
"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"
|
||||
nbid "github.com/netbirdio/netbird/client/internal/acl/id"
|
||||
"github.com/netbirdio/netbird/client/internal/routemanager/ipfwdstate"
|
||||
@@ -36,9 +37,12 @@ const (
|
||||
chainNameRoutingFw = "netbird-rt-fwd"
|
||||
chainNameRoutingNat = "netbird-rt-postrouting"
|
||||
chainNameRoutingRdr = "netbird-rt-redirect"
|
||||
chainNameNATOutput = "netbird-nat-output"
|
||||
chainNameForward = "FORWARD"
|
||||
chainNameMangleForward = "netbird-mangle-forward"
|
||||
|
||||
firewalldTableName = "firewalld"
|
||||
|
||||
userDataAcceptForwardRuleIif = "frwacceptiif"
|
||||
userDataAcceptForwardRuleOif = "frwacceptoif"
|
||||
userDataAcceptInputRule = "inputaccept"
|
||||
@@ -132,6 +136,10 @@ func (r *router) Reset() error {
|
||||
merr = multierror.Append(merr, fmt.Errorf("remove accept filter rules: %w", err))
|
||||
}
|
||||
|
||||
if err := firewalld.UntrustInterface(r.wgIface.Name()); err != nil {
|
||||
merr = multierror.Append(merr, err)
|
||||
}
|
||||
|
||||
if err := r.removeNatPreroutingRules(); err != nil {
|
||||
merr = multierror.Append(merr, fmt.Errorf("remove filter prerouting rules: %w", err))
|
||||
}
|
||||
@@ -279,6 +287,10 @@ func (r *router) createContainers() error {
|
||||
log.Errorf("failed to add accept rules for the forward chain: %s", err)
|
||||
}
|
||||
|
||||
if err := firewalld.TrustInterface(r.wgIface.Name()); err != nil {
|
||||
log.Warnf("failed to trust interface in firewalld: %v", err)
|
||||
}
|
||||
|
||||
if err := r.refreshRulesMap(); err != nil {
|
||||
log.Errorf("failed to refresh rules: %s", err)
|
||||
}
|
||||
@@ -1318,6 +1330,13 @@ func (r *router) isExternalChain(chain *nftables.Chain) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// Skip firewalld-owned chains. Firewalld creates its chains with the
|
||||
// NFT_CHAIN_OWNER flag, so inserting rules into them returns EPERM.
|
||||
// We delegate acceptance to firewalld by trusting the interface instead.
|
||||
if chain.Table.Name == firewalldTableName {
|
||||
return false
|
||||
}
|
||||
|
||||
// Skip all iptables-managed tables in the ip family
|
||||
if chain.Table.Family == nftables.TableFamilyIPv4 && isIptablesTable(chain.Table.Name) {
|
||||
return false
|
||||
@@ -1853,6 +1872,130 @@ func (r *router) RemoveInboundDNAT(localAddr netip.Addr, protocol firewall.Proto
|
||||
return nil
|
||||
}
|
||||
|
||||
// ensureNATOutputChain lazily creates the OUTPUT NAT chain on first use.
|
||||
func (r *router) ensureNATOutputChain() error {
|
||||
if _, exists := r.chains[chainNameNATOutput]; exists {
|
||||
return nil
|
||||
}
|
||||
|
||||
r.chains[chainNameNATOutput] = r.conn.AddChain(&nftables.Chain{
|
||||
Name: chainNameNATOutput,
|
||||
Table: r.workTable,
|
||||
Hooknum: nftables.ChainHookOutput,
|
||||
Priority: nftables.ChainPriorityNATDest,
|
||||
Type: nftables.ChainTypeNAT,
|
||||
})
|
||||
|
||||
if err := r.conn.Flush(); err != nil {
|
||||
delete(r.chains, chainNameNATOutput)
|
||||
return fmt.Errorf("create NAT output chain: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// AddOutputDNAT adds an OUTPUT chain DNAT rule for locally-generated traffic.
|
||||
func (r *router) AddOutputDNAT(localAddr netip.Addr, protocol firewall.Protocol, sourcePort, targetPort uint16) error {
|
||||
ruleID := fmt.Sprintf("output-dnat-%s-%s-%d-%d", localAddr.String(), protocol, sourcePort, targetPort)
|
||||
|
||||
if _, exists := r.rules[ruleID]; exists {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := r.ensureNATOutputChain(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
protoNum, err := protoToInt(protocol)
|
||||
if err != nil {
|
||||
return fmt.Errorf("convert protocol to number: %w", err)
|
||||
}
|
||||
|
||||
exprs := []expr.Any{
|
||||
&expr.Meta{Key: expr.MetaKeyL4PROTO, Register: 1},
|
||||
&expr.Cmp{
|
||||
Op: expr.CmpOpEq,
|
||||
Register: 1,
|
||||
Data: []byte{protoNum},
|
||||
},
|
||||
&expr.Payload{
|
||||
DestRegister: 2,
|
||||
Base: expr.PayloadBaseTransportHeader,
|
||||
Offset: 2,
|
||||
Len: 2,
|
||||
},
|
||||
&expr.Cmp{
|
||||
Op: expr.CmpOpEq,
|
||||
Register: 2,
|
||||
Data: binaryutil.BigEndian.PutUint16(sourcePort),
|
||||
},
|
||||
}
|
||||
|
||||
exprs = append(exprs, applyPrefix(netip.PrefixFrom(localAddr, 32), false)...)
|
||||
|
||||
exprs = append(exprs,
|
||||
&expr.Immediate{
|
||||
Register: 1,
|
||||
Data: localAddr.AsSlice(),
|
||||
},
|
||||
&expr.Immediate{
|
||||
Register: 2,
|
||||
Data: binaryutil.BigEndian.PutUint16(targetPort),
|
||||
},
|
||||
&expr.NAT{
|
||||
Type: expr.NATTypeDestNAT,
|
||||
Family: uint32(nftables.TableFamilyIPv4),
|
||||
RegAddrMin: 1,
|
||||
RegProtoMin: 2,
|
||||
},
|
||||
)
|
||||
|
||||
dnatRule := &nftables.Rule{
|
||||
Table: r.workTable,
|
||||
Chain: r.chains[chainNameNATOutput],
|
||||
Exprs: exprs,
|
||||
UserData: []byte(ruleID),
|
||||
}
|
||||
r.conn.AddRule(dnatRule)
|
||||
|
||||
if err := r.conn.Flush(); err != nil {
|
||||
return fmt.Errorf("add output DNAT rule: %w", err)
|
||||
}
|
||||
|
||||
r.rules[ruleID] = dnatRule
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// RemoveOutputDNAT removes an OUTPUT chain DNAT rule.
|
||||
func (r *router) RemoveOutputDNAT(localAddr netip.Addr, protocol firewall.Protocol, sourcePort, targetPort uint16) error {
|
||||
if err := r.refreshRulesMap(); err != nil {
|
||||
return fmt.Errorf(refreshRulesMapError, err)
|
||||
}
|
||||
|
||||
ruleID := fmt.Sprintf("output-dnat-%s-%s-%d-%d", localAddr.String(), protocol, sourcePort, targetPort)
|
||||
|
||||
rule, exists := r.rules[ruleID]
|
||||
if !exists {
|
||||
return nil
|
||||
}
|
||||
|
||||
if rule.Handle == 0 {
|
||||
log.Warnf("output DNAT rule %s has no handle, removing stale entry", ruleID)
|
||||
delete(r.rules, ruleID)
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := r.conn.DelRule(rule); err != nil {
|
||||
return fmt.Errorf("delete output DNAT rule %s: %w", ruleID, err)
|
||||
}
|
||||
if err := r.conn.Flush(); err != nil {
|
||||
return fmt.Errorf("flush delete output DNAT rule: %w", err)
|
||||
}
|
||||
delete(r.rules, ruleID)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// applyNetwork generates nftables expressions for networks (CIDR) or sets
|
||||
func (r *router) applyNetwork(
|
||||
network firewall.Network,
|
||||
|
||||
@@ -8,10 +8,9 @@ import (
|
||||
)
|
||||
|
||||
type InterfaceState struct {
|
||||
NameStr string `json:"name"`
|
||||
WGAddress wgaddr.Address `json:"wg_address"`
|
||||
UserspaceBind bool `json:"userspace_bind"`
|
||||
MTU uint16 `json:"mtu"`
|
||||
NameStr string `json:"name"`
|
||||
WGAddress wgaddr.Address `json:"wg_address"`
|
||||
MTU uint16 `json:"mtu"`
|
||||
}
|
||||
|
||||
func (i *InterfaceState) Name() string {
|
||||
@@ -22,10 +21,6 @@ func (i *InterfaceState) Address() wgaddr.Address {
|
||||
return i.WGAddress
|
||||
}
|
||||
|
||||
func (i *InterfaceState) IsUserspaceBind() bool {
|
||||
return i.UserspaceBind
|
||||
}
|
||||
|
||||
type ShutdownState struct {
|
||||
InterfaceState *InterfaceState `json:"interface_state,omitempty"`
|
||||
}
|
||||
|
||||
@@ -3,6 +3,9 @@
|
||||
package uspfilter
|
||||
|
||||
import (
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/netbirdio/netbird/client/firewall/firewalld"
|
||||
"github.com/netbirdio/netbird/client/internal/statemanager"
|
||||
)
|
||||
|
||||
@@ -16,6 +19,9 @@ func (m *Manager) Close(stateManager *statemanager.Manager) error {
|
||||
if m.nativeFirewall != nil {
|
||||
return m.nativeFirewall.Close(stateManager)
|
||||
}
|
||||
if err := firewalld.UntrustInterface(m.wgIface.Name()); err != nil {
|
||||
log.Warnf("failed to untrust interface in firewalld: %v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -24,5 +30,8 @@ func (m *Manager) AllowNetbird() error {
|
||||
if m.nativeFirewall != nil {
|
||||
return m.nativeFirewall.AllowNetbird()
|
||||
}
|
||||
if err := firewalld.TrustInterface(m.wgIface.Name()); err != nil {
|
||||
log.Warnf("failed to trust interface in firewalld: %v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
37
client/firewall/uspfilter/common/hooks.go
Normal file
@@ -0,0 +1,37 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"net/netip"
|
||||
"sync/atomic"
|
||||
)
|
||||
|
||||
// PacketHook stores a registered hook for a specific IP:port.
|
||||
type PacketHook struct {
|
||||
IP netip.Addr
|
||||
Port uint16
|
||||
Fn func([]byte) bool
|
||||
}
|
||||
|
||||
// HookMatches checks if a packet's destination matches the hook and invokes it.
|
||||
func HookMatches(h *PacketHook, dstIP netip.Addr, dport uint16, packetData []byte) bool {
|
||||
if h == nil {
|
||||
return false
|
||||
}
|
||||
if h.IP == dstIP && h.Port == dport {
|
||||
return h.Fn(packetData)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// SetHook atomically stores a hook, handling nil removal.
|
||||
func SetHook(ptr *atomic.Pointer[PacketHook], ip netip.Addr, dPort uint16, hook func([]byte) bool) {
|
||||
if hook == nil {
|
||||
ptr.Store(nil)
|
||||
return
|
||||
}
|
||||
ptr.Store(&PacketHook{
|
||||
IP: ip,
|
||||
Port: dPort,
|
||||
Fn: hook,
|
||||
})
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
|
||||
// IFaceMapper defines subset methods of interface required for manager
|
||||
type IFaceMapper interface {
|
||||
Name() string
|
||||
SetFilter(device.PacketFilter) error
|
||||
Address() wgaddr.Address
|
||||
GetWGDevice() *wgdevice.Device
|
||||
|
||||
@@ -140,6 +140,10 @@ type Manager struct {
|
||||
mtu uint16
|
||||
mssClampValue uint16
|
||||
mssClampEnabled bool
|
||||
|
||||
// Only one hook per protocol is supported. Outbound direction only.
|
||||
udpHookOut atomic.Pointer[common.PacketHook]
|
||||
tcpHookOut atomic.Pointer[common.PacketHook]
|
||||
}
|
||||
|
||||
// decoder for packages
|
||||
@@ -594,6 +598,8 @@ func (m *Manager) resetState() {
|
||||
maps.Clear(m.incomingRules)
|
||||
maps.Clear(m.routeRulesMap)
|
||||
m.routeRules = m.routeRules[:0]
|
||||
m.udpHookOut.Store(nil)
|
||||
m.tcpHookOut.Store(nil)
|
||||
|
||||
if m.udpTracker != nil {
|
||||
m.udpTracker.Close()
|
||||
@@ -713,6 +719,9 @@ func (m *Manager) filterOutbound(packetData []byte, size int) bool {
|
||||
return true
|
||||
}
|
||||
case layers.LayerTypeTCP:
|
||||
if m.tcpHooksDrop(uint16(d.tcp.DstPort), dstIP, packetData) {
|
||||
return true
|
||||
}
|
||||
// Clamp MSS on all TCP SYN packets, including those from local IPs.
|
||||
// SNATed routed traffic may appear as local IP but still requires clamping.
|
||||
if m.mssClampEnabled {
|
||||
@@ -895,39 +904,12 @@ func (m *Manager) trackInbound(d *decoder, srcIP, dstIP netip.Addr, ruleID []byt
|
||||
d.dnatOrigPort = 0
|
||||
}
|
||||
|
||||
// udpHooksDrop checks if any UDP hooks should drop the packet
|
||||
func (m *Manager) udpHooksDrop(dport uint16, dstIP netip.Addr, packetData []byte) bool {
|
||||
m.mutex.RLock()
|
||||
defer m.mutex.RUnlock()
|
||||
return common.HookMatches(m.udpHookOut.Load(), dstIP, dport, packetData)
|
||||
}
|
||||
|
||||
// Check specific destination IP first
|
||||
if rules, exists := m.outgoingRules[dstIP]; exists {
|
||||
for _, rule := range rules {
|
||||
if rule.udpHook != nil && portsMatch(rule.dPort, dport) {
|
||||
return rule.udpHook(packetData)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check IPv4 unspecified address
|
||||
if rules, exists := m.outgoingRules[netip.IPv4Unspecified()]; exists {
|
||||
for _, rule := range rules {
|
||||
if rule.udpHook != nil && portsMatch(rule.dPort, dport) {
|
||||
return rule.udpHook(packetData)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check IPv6 unspecified address
|
||||
if rules, exists := m.outgoingRules[netip.IPv6Unspecified()]; exists {
|
||||
for _, rule := range rules {
|
||||
if rule.udpHook != nil && portsMatch(rule.dPort, dport) {
|
||||
return rule.udpHook(packetData)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
func (m *Manager) tcpHooksDrop(dport uint16, dstIP netip.Addr, packetData []byte) bool {
|
||||
return common.HookMatches(m.tcpHookOut.Load(), dstIP, dport, packetData)
|
||||
}
|
||||
|
||||
// filterInbound implements filtering logic for incoming packets.
|
||||
@@ -1278,12 +1260,6 @@ func validateRule(ip netip.Addr, packetData []byte, rules map[string]PeerRule, d
|
||||
return rule.mgmtId, rule.drop, true
|
||||
}
|
||||
case layers.LayerTypeUDP:
|
||||
// if rule has UDP hook (and if we are here we match this rule)
|
||||
// we ignore rule.drop and call this hook
|
||||
if rule.udpHook != nil {
|
||||
return rule.mgmtId, rule.udpHook(packetData), true
|
||||
}
|
||||
|
||||
if portsMatch(rule.sPort, uint16(d.udp.SrcPort)) && portsMatch(rule.dPort, uint16(d.udp.DstPort)) {
|
||||
return rule.mgmtId, rule.drop, true
|
||||
}
|
||||
@@ -1342,65 +1318,14 @@ func (m *Manager) ruleMatches(rule *RouteRule, srcAddr, dstAddr netip.Addr, prot
|
||||
return sourceMatched
|
||||
}
|
||||
|
||||
// AddUDPPacketHook calls hook when UDP packet from given direction matched
|
||||
//
|
||||
// Hook function returns flag which indicates should be the matched package dropped or not
|
||||
func (m *Manager) AddUDPPacketHook(in bool, ip netip.Addr, dPort uint16, hook func(packet []byte) bool) string {
|
||||
r := PeerRule{
|
||||
id: uuid.New().String(),
|
||||
ip: ip,
|
||||
protoLayer: layers.LayerTypeUDP,
|
||||
dPort: &firewall.Port{Values: []uint16{dPort}},
|
||||
ipLayer: layers.LayerTypeIPv6,
|
||||
udpHook: hook,
|
||||
}
|
||||
|
||||
if ip.Is4() {
|
||||
r.ipLayer = layers.LayerTypeIPv4
|
||||
}
|
||||
|
||||
m.mutex.Lock()
|
||||
if in {
|
||||
// Incoming UDP hooks are stored in allow rules map
|
||||
if _, ok := m.incomingRules[r.ip]; !ok {
|
||||
m.incomingRules[r.ip] = make(map[string]PeerRule)
|
||||
}
|
||||
m.incomingRules[r.ip][r.id] = r
|
||||
} else {
|
||||
if _, ok := m.outgoingRules[r.ip]; !ok {
|
||||
m.outgoingRules[r.ip] = make(map[string]PeerRule)
|
||||
}
|
||||
m.outgoingRules[r.ip][r.id] = r
|
||||
}
|
||||
m.mutex.Unlock()
|
||||
|
||||
return r.id
|
||||
// SetUDPPacketHook sets the outbound UDP packet hook. Pass nil hook to remove.
|
||||
func (m *Manager) SetUDPPacketHook(ip netip.Addr, dPort uint16, hook func(packet []byte) bool) {
|
||||
common.SetHook(&m.udpHookOut, ip, dPort, hook)
|
||||
}
|
||||
|
||||
// RemovePacketHook removes packet hook by given ID
|
||||
func (m *Manager) RemovePacketHook(hookID string) error {
|
||||
m.mutex.Lock()
|
||||
defer m.mutex.Unlock()
|
||||
|
||||
// Check incoming hooks (stored in allow rules)
|
||||
for _, arr := range m.incomingRules {
|
||||
for _, r := range arr {
|
||||
if r.id == hookID {
|
||||
delete(arr, r.id)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
// Check outgoing hooks
|
||||
for _, arr := range m.outgoingRules {
|
||||
for _, r := range arr {
|
||||
if r.id == hookID {
|
||||
delete(arr, r.id)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("hook with given id not found")
|
||||
// SetTCPPacketHook sets the outbound TCP packet hook. Pass nil hook to remove.
|
||||
func (m *Manager) SetTCPPacketHook(ip netip.Addr, dPort uint16, hook func(packet []byte) bool) {
|
||||
common.SetHook(&m.tcpHookOut, ip, dPort, hook)
|
||||
}
|
||||
|
||||
// SetLogLevel sets the log level for the firewall manager
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"github.com/google/gopacket"
|
||||
"github.com/google/gopacket/layers"
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
wgdevice "golang.zx2c4.com/wireguard/device"
|
||||
|
||||
@@ -30,12 +31,20 @@ var logger = log.NewFromLogrus(logrus.StandardLogger())
|
||||
var flowLogger = netflow.NewManager(nil, []byte{}, nil).GetLogger()
|
||||
|
||||
type IFaceMock struct {
|
||||
NameFunc func() string
|
||||
SetFilterFunc func(device.PacketFilter) error
|
||||
AddressFunc func() wgaddr.Address
|
||||
GetWGDeviceFunc func() *wgdevice.Device
|
||||
GetDeviceFunc func() *device.FilteredDevice
|
||||
}
|
||||
|
||||
func (i *IFaceMock) Name() string {
|
||||
if i.NameFunc == nil {
|
||||
return "wgtest"
|
||||
}
|
||||
return i.NameFunc()
|
||||
}
|
||||
|
||||
func (i *IFaceMock) GetWGDevice() *wgdevice.Device {
|
||||
if i.GetWGDeviceFunc == nil {
|
||||
return nil
|
||||
@@ -186,81 +195,52 @@ func TestManagerDeleteRule(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddUDPPacketHook(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
in bool
|
||||
expDir fw.RuleDirection
|
||||
ip netip.Addr
|
||||
dPort uint16
|
||||
hook func([]byte) bool
|
||||
expectedID string
|
||||
}{
|
||||
{
|
||||
name: "Test Outgoing UDP Packet Hook",
|
||||
in: false,
|
||||
expDir: fw.RuleDirectionOUT,
|
||||
ip: netip.MustParseAddr("10.168.0.1"),
|
||||
dPort: 8000,
|
||||
hook: func([]byte) bool { return true },
|
||||
},
|
||||
{
|
||||
name: "Test Incoming UDP Packet Hook",
|
||||
in: true,
|
||||
expDir: fw.RuleDirectionIN,
|
||||
ip: netip.MustParseAddr("::1"),
|
||||
dPort: 9000,
|
||||
hook: func([]byte) bool { return false },
|
||||
},
|
||||
}
|
||||
func TestSetUDPPacketHook(t *testing.T) {
|
||||
manager, err := Create(&IFaceMock{
|
||||
SetFilterFunc: func(device.PacketFilter) error { return nil },
|
||||
}, false, flowLogger, nbiface.DefaultMTU)
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { require.NoError(t, manager.Close(nil)) })
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
manager, err := Create(&IFaceMock{
|
||||
SetFilterFunc: func(device.PacketFilter) error { return nil },
|
||||
}, false, flowLogger, nbiface.DefaultMTU)
|
||||
require.NoError(t, err)
|
||||
var called bool
|
||||
manager.SetUDPPacketHook(netip.MustParseAddr("10.168.0.1"), 8000, func([]byte) bool {
|
||||
called = true
|
||||
return true
|
||||
})
|
||||
|
||||
manager.AddUDPPacketHook(tt.in, tt.ip, tt.dPort, tt.hook)
|
||||
h := manager.udpHookOut.Load()
|
||||
require.NotNil(t, h)
|
||||
assert.Equal(t, netip.MustParseAddr("10.168.0.1"), h.IP)
|
||||
assert.Equal(t, uint16(8000), h.Port)
|
||||
assert.True(t, h.Fn(nil))
|
||||
assert.True(t, called)
|
||||
|
||||
var addedRule PeerRule
|
||||
if tt.in {
|
||||
// Incoming UDP hooks are stored in allow rules map
|
||||
if len(manager.incomingRules[tt.ip]) != 1 {
|
||||
t.Errorf("expected 1 incoming rule, got %d", len(manager.incomingRules[tt.ip]))
|
||||
return
|
||||
}
|
||||
for _, rule := range manager.incomingRules[tt.ip] {
|
||||
addedRule = rule
|
||||
}
|
||||
} else {
|
||||
if len(manager.outgoingRules[tt.ip]) != 1 {
|
||||
t.Errorf("expected 1 outgoing rule, got %d", len(manager.outgoingRules[tt.ip]))
|
||||
return
|
||||
}
|
||||
for _, rule := range manager.outgoingRules[tt.ip] {
|
||||
addedRule = rule
|
||||
}
|
||||
}
|
||||
manager.SetUDPPacketHook(netip.MustParseAddr("10.168.0.1"), 8000, nil)
|
||||
assert.Nil(t, manager.udpHookOut.Load())
|
||||
}
|
||||
|
||||
if tt.ip.Compare(addedRule.ip) != 0 {
|
||||
t.Errorf("expected ip %s, got %s", tt.ip, addedRule.ip)
|
||||
return
|
||||
}
|
||||
if tt.dPort != addedRule.dPort.Values[0] {
|
||||
t.Errorf("expected dPort %d, got %d", tt.dPort, addedRule.dPort.Values[0])
|
||||
return
|
||||
}
|
||||
if layers.LayerTypeUDP != addedRule.protoLayer {
|
||||
t.Errorf("expected protoLayer %s, got %s", layers.LayerTypeUDP, addedRule.protoLayer)
|
||||
return
|
||||
}
|
||||
if addedRule.udpHook == nil {
|
||||
t.Errorf("expected udpHook to be set")
|
||||
return
|
||||
}
|
||||
})
|
||||
}
|
||||
func TestSetTCPPacketHook(t *testing.T) {
|
||||
manager, err := Create(&IFaceMock{
|
||||
SetFilterFunc: func(device.PacketFilter) error { return nil },
|
||||
}, false, flowLogger, nbiface.DefaultMTU)
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { require.NoError(t, manager.Close(nil)) })
|
||||
|
||||
var called bool
|
||||
manager.SetTCPPacketHook(netip.MustParseAddr("10.168.0.1"), 53, func([]byte) bool {
|
||||
called = true
|
||||
return true
|
||||
})
|
||||
|
||||
h := manager.tcpHookOut.Load()
|
||||
require.NotNil(t, h)
|
||||
assert.Equal(t, netip.MustParseAddr("10.168.0.1"), h.IP)
|
||||
assert.Equal(t, uint16(53), h.Port)
|
||||
assert.True(t, h.Fn(nil))
|
||||
assert.True(t, called)
|
||||
|
||||
manager.SetTCPPacketHook(netip.MustParseAddr("10.168.0.1"), 53, nil)
|
||||
assert.Nil(t, manager.tcpHookOut.Load())
|
||||
}
|
||||
|
||||
// TestPeerRuleLifecycleDenyRules verifies that deny rules are correctly added
|
||||
@@ -530,39 +510,12 @@ func TestRemovePacketHook(t *testing.T) {
|
||||
require.NoError(t, manager.Close(nil))
|
||||
}()
|
||||
|
||||
// Add a UDP packet hook
|
||||
hookFunc := func(data []byte) bool { return true }
|
||||
hookID := manager.AddUDPPacketHook(false, netip.MustParseAddr("192.168.0.1"), 8080, hookFunc)
|
||||
manager.SetUDPPacketHook(netip.MustParseAddr("192.168.0.1"), 8080, func([]byte) bool { return true })
|
||||
|
||||
// Assert the hook is added by finding it in the manager's outgoing rules
|
||||
found := false
|
||||
for _, arr := range manager.outgoingRules {
|
||||
for _, rule := range arr {
|
||||
if rule.id == hookID {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
require.NotNil(t, manager.udpHookOut.Load(), "hook should be registered")
|
||||
|
||||
if !found {
|
||||
t.Fatalf("The hook was not added properly.")
|
||||
}
|
||||
|
||||
// Now remove the packet hook
|
||||
err = manager.RemovePacketHook(hookID)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to remove hook: %s", err)
|
||||
}
|
||||
|
||||
// Assert the hook is removed by checking it in the manager's outgoing rules
|
||||
for _, arr := range manager.outgoingRules {
|
||||
for _, rule := range arr {
|
||||
if rule.id == hookID {
|
||||
t.Fatalf("The hook was not removed properly.")
|
||||
}
|
||||
}
|
||||
}
|
||||
manager.SetUDPPacketHook(netip.MustParseAddr("192.168.0.1"), 8080, nil)
|
||||
assert.Nil(t, manager.udpHookOut.Load(), "hook should be removed")
|
||||
}
|
||||
|
||||
func TestProcessOutgoingHooks(t *testing.T) {
|
||||
@@ -592,8 +545,7 @@ func TestProcessOutgoingHooks(t *testing.T) {
|
||||
}
|
||||
|
||||
hookCalled := false
|
||||
hookID := manager.AddUDPPacketHook(
|
||||
false,
|
||||
manager.SetUDPPacketHook(
|
||||
netip.MustParseAddr("100.10.0.100"),
|
||||
53,
|
||||
func([]byte) bool {
|
||||
@@ -601,7 +553,6 @@ func TestProcessOutgoingHooks(t *testing.T) {
|
||||
return true
|
||||
},
|
||||
)
|
||||
require.NotEmpty(t, hookID)
|
||||
|
||||
// Create test UDP packet
|
||||
ipv4 := &layers.IPv4{
|
||||
|
||||
90
client/firewall/uspfilter/hooks_filter.go
Normal file
@@ -0,0 +1,90 @@
|
||||
package uspfilter
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"net/netip"
|
||||
"sync/atomic"
|
||||
|
||||
"github.com/netbirdio/netbird/client/firewall/uspfilter/common"
|
||||
"github.com/netbirdio/netbird/client/iface/device"
|
||||
)
|
||||
|
||||
const (
|
||||
ipv4HeaderMinLen = 20
|
||||
ipv4ProtoOffset = 9
|
||||
ipv4FlagsOffset = 6
|
||||
ipv4DstOffset = 16
|
||||
ipProtoUDP = 17
|
||||
ipProtoTCP = 6
|
||||
ipv4FragOffMask = 0x1fff
|
||||
// dstPortOffset is the offset of the destination port within a UDP or TCP header.
|
||||
dstPortOffset = 2
|
||||
)
|
||||
|
||||
// HooksFilter is a minimal packet filter that only handles outbound DNS hooks.
|
||||
// It is installed on the WireGuard interface when the userspace bind is active
|
||||
// but a full firewall filter (Manager) is not needed because a native kernel
|
||||
// firewall (nftables/iptables) handles packet filtering.
|
||||
type HooksFilter struct {
|
||||
udpHook atomic.Pointer[common.PacketHook]
|
||||
tcpHook atomic.Pointer[common.PacketHook]
|
||||
}
|
||||
|
||||
var _ device.PacketFilter = (*HooksFilter)(nil)
|
||||
|
||||
// FilterOutbound checks outbound packets for DNS hook matches.
|
||||
// Only IPv4 packets matching the registered hook IP:port are intercepted.
|
||||
// IPv6 and non-IP packets pass through unconditionally.
|
||||
func (f *HooksFilter) FilterOutbound(packetData []byte, _ int) bool {
|
||||
if len(packetData) < ipv4HeaderMinLen {
|
||||
return false
|
||||
}
|
||||
|
||||
// Only process IPv4 packets, let everything else pass through.
|
||||
if packetData[0]>>4 != 4 {
|
||||
return false
|
||||
}
|
||||
|
||||
ihl := int(packetData[0]&0x0f) * 4
|
||||
if ihl < ipv4HeaderMinLen || len(packetData) < ihl+4 {
|
||||
return false
|
||||
}
|
||||
|
||||
// Skip non-first fragments: they don't carry L4 headers.
|
||||
flagsAndOffset := binary.BigEndian.Uint16(packetData[ipv4FlagsOffset : ipv4FlagsOffset+2])
|
||||
if flagsAndOffset&ipv4FragOffMask != 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
dstIP, ok := netip.AddrFromSlice(packetData[ipv4DstOffset : ipv4DstOffset+4])
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
|
||||
proto := packetData[ipv4ProtoOffset]
|
||||
dstPort := binary.BigEndian.Uint16(packetData[ihl+dstPortOffset : ihl+dstPortOffset+2])
|
||||
|
||||
switch proto {
|
||||
case ipProtoUDP:
|
||||
return common.HookMatches(f.udpHook.Load(), dstIP, dstPort, packetData)
|
||||
case ipProtoTCP:
|
||||
return common.HookMatches(f.tcpHook.Load(), dstIP, dstPort, packetData)
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// FilterInbound allows all inbound packets (native firewall handles filtering).
|
||||
func (f *HooksFilter) FilterInbound([]byte, int) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// SetUDPPacketHook registers the UDP packet hook.
|
||||
func (f *HooksFilter) SetUDPPacketHook(ip netip.Addr, dPort uint16, hook func([]byte) bool) {
|
||||
common.SetHook(&f.udpHook, ip, dPort, hook)
|
||||
}
|
||||
|
||||
// SetTCPPacketHook registers the TCP packet hook.
|
||||
func (f *HooksFilter) SetTCPPacketHook(ip netip.Addr, dPort uint16, hook func([]byte) bool) {
|
||||
common.SetHook(&f.tcpHook, ip, dPort, hook)
|
||||
}
|
||||
@@ -144,6 +144,8 @@ func (m *localIPManager) UpdateLocalIPs(iface common.IFaceMapper) (err error) {
|
||||
if err != nil {
|
||||
log.Warnf("failed to get interfaces: %v", err)
|
||||
} else {
|
||||
// TODO: filter out down interfaces (net.FlagUp). Also handle the reverse
|
||||
// case where an interface comes up between refreshes.
|
||||
for _, intf := range interfaces {
|
||||
m.processInterface(intf, &newIPv4Bitmap, ipv4Set, &ipv4Addresses)
|
||||
}
|
||||
|
||||
@@ -421,6 +421,7 @@ func (m *Manager) addPortRedirection(targetIP netip.Addr, protocol gopacket.Laye
|
||||
}
|
||||
|
||||
// AddInboundDNAT adds an inbound DNAT rule redirecting traffic from NetBird peers to local services.
|
||||
// TODO: also delegate to nativeFirewall when available for kernel WG mode
|
||||
func (m *Manager) AddInboundDNAT(localAddr netip.Addr, protocol firewall.Protocol, sourcePort, targetPort uint16) error {
|
||||
var layerType gopacket.LayerType
|
||||
switch protocol {
|
||||
@@ -466,6 +467,22 @@ func (m *Manager) RemoveInboundDNAT(localAddr netip.Addr, protocol firewall.Prot
|
||||
return m.removePortRedirection(localAddr, layerType, sourcePort, targetPort)
|
||||
}
|
||||
|
||||
// AddOutputDNAT delegates to the native firewall if available.
|
||||
func (m *Manager) AddOutputDNAT(localAddr netip.Addr, protocol firewall.Protocol, sourcePort, targetPort uint16) error {
|
||||
if m.nativeFirewall == nil {
|
||||
return fmt.Errorf("output DNAT not supported without native firewall")
|
||||
}
|
||||
return m.nativeFirewall.AddOutputDNAT(localAddr, protocol, sourcePort, targetPort)
|
||||
}
|
||||
|
||||
// RemoveOutputDNAT delegates to the native firewall if available.
|
||||
func (m *Manager) RemoveOutputDNAT(localAddr netip.Addr, protocol firewall.Protocol, sourcePort, targetPort uint16) error {
|
||||
if m.nativeFirewall == nil {
|
||||
return nil
|
||||
}
|
||||
return m.nativeFirewall.RemoveOutputDNAT(localAddr, protocol, sourcePort, targetPort)
|
||||
}
|
||||
|
||||
// translateInboundPortDNAT applies port-specific DNAT translation to inbound packets.
|
||||
func (m *Manager) translateInboundPortDNAT(packetData []byte, d *decoder, srcIP, dstIP netip.Addr) bool {
|
||||
if !m.portDNATEnabled.Load() {
|
||||
|
||||
@@ -18,9 +18,7 @@ type PeerRule struct {
|
||||
protoLayer gopacket.LayerType
|
||||
sPort *firewall.Port
|
||||
dPort *firewall.Port
|
||||
drop bool
|
||||
|
||||
udpHook func([]byte) bool
|
||||
drop bool
|
||||
}
|
||||
|
||||
// ID returns the rule id
|
||||
|
||||
@@ -399,21 +399,17 @@ func TestTracePacket(t *testing.T) {
|
||||
{
|
||||
name: "UDPTraffic_WithHook",
|
||||
setup: func(m *Manager) {
|
||||
hookFunc := func([]byte) bool {
|
||||
return true
|
||||
}
|
||||
m.AddUDPPacketHook(true, netip.MustParseAddr("1.1.1.1"), 53, hookFunc)
|
||||
m.SetUDPPacketHook(netip.MustParseAddr("100.10.255.254"), 53, func([]byte) bool {
|
||||
return true // drop (intercepted by hook)
|
||||
})
|
||||
},
|
||||
packetBuilder: func() *PacketBuilder {
|
||||
return createPacketBuilder("1.1.1.1", "100.10.0.100", "udp", 12345, 53, fw.RuleDirectionIN)
|
||||
return createPacketBuilder("100.10.0.100", "100.10.255.254", "udp", 12345, 53, fw.RuleDirectionOUT)
|
||||
},
|
||||
expectedStages: []PacketStage{
|
||||
StageReceived,
|
||||
StageInboundPortDNAT,
|
||||
StageInbound1to1NAT,
|
||||
StageConntrack,
|
||||
StageRouting,
|
||||
StagePeerACL,
|
||||
StageOutbound1to1NAT,
|
||||
StageOutboundPortReverse,
|
||||
StageCompleted,
|
||||
},
|
||||
expectedAllow: false,
|
||||
|
||||
6
client/flutter_ui/.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
.dart_tool/
|
||||
.flutter-plugins
|
||||
.flutter-plugins-dependencies
|
||||
.packages
|
||||
build/
|
||||
coverage/
|
||||
36
client/flutter_ui/.metadata
Normal file
@@ -0,0 +1,36 @@
|
||||
# This file tracks properties of this Flutter project.
|
||||
# Used by Flutter tool to assess capabilities and perform upgrades etc.
|
||||
#
|
||||
# This file should be version controlled and should not be manually edited.
|
||||
|
||||
version:
|
||||
revision: "02085feb3f5d8a8156e5e28512b9d99351d510c0"
|
||||
channel: "stable"
|
||||
|
||||
project_type: app
|
||||
|
||||
# Tracks metadata for the flutter migrate command
|
||||
migration:
|
||||
platforms:
|
||||
- platform: root
|
||||
create_revision: 02085feb3f5d8a8156e5e28512b9d99351d510c0
|
||||
base_revision: 02085feb3f5d8a8156e5e28512b9d99351d510c0
|
||||
- platform: linux
|
||||
create_revision: 02085feb3f5d8a8156e5e28512b9d99351d510c0
|
||||
base_revision: 02085feb3f5d8a8156e5e28512b9d99351d510c0
|
||||
- platform: macos
|
||||
create_revision: 02085feb3f5d8a8156e5e28512b9d99351d510c0
|
||||
base_revision: 02085feb3f5d8a8156e5e28512b9d99351d510c0
|
||||
- platform: windows
|
||||
create_revision: 02085feb3f5d8a8156e5e28512b9d99351d510c0
|
||||
base_revision: 02085feb3f5d8a8156e5e28512b9d99351d510c0
|
||||
|
||||
# User provided section
|
||||
|
||||
# List of Local paths (relative to this file) that should be
|
||||
# ignored by the migrate tool.
|
||||
#
|
||||
# Files that are not part of the templates will be ignored by default.
|
||||
unmanaged_files:
|
||||
- 'lib/main.dart'
|
||||
- 'ios/Runner.xcodeproj/project.pbxproj'
|
||||
115
client/flutter_ui/MIGRATION.md
Normal file
@@ -0,0 +1,115 @@
|
||||
# Flutter UI Migration
|
||||
|
||||
## Current Boundary
|
||||
|
||||
Keep the daemon as-is and replace only the desktop UI process. The Flutter app
|
||||
should continue to talk to `DaemonService` from `client/proto/daemon.proto`.
|
||||
|
||||
The current UI is not a simple settings window. It owns:
|
||||
|
||||
- tray/menu-bar state and nested menu actions
|
||||
- gRPC connection management and event subscription
|
||||
- connect, disconnect, login, and session-expired flows
|
||||
- profile switching, deregistration, and profile windows
|
||||
- network route and exit-node selection
|
||||
- advanced settings
|
||||
- debug bundle creation and upload status dialogs
|
||||
- enforced update notifications and progress windows
|
||||
- OS sleep/wake notification to the daemon
|
||||
- single-instance signaling and quick-actions windows
|
||||
|
||||
## Phases
|
||||
|
||||
1. Scaffold and generated gRPC client
|
||||
- Done: generated Dart stubs from `client/proto/daemon.proto`.
|
||||
- Done: app defaults to a gRPC-backed implementation and keeps
|
||||
`--fake-daemon` for UI-only work.
|
||||
- Remaining: replace the development user agent suffix with the release
|
||||
version at build time.
|
||||
|
||||
2. Core connection parity
|
||||
- Done: status polling and `SubscribeEvents` refresh hooks.
|
||||
- Done: `connect()` runs `Login` → optional SSO browser handoff via
|
||||
`openExternalUrl` → `WaitSSOLogin` → `Up`, with an `awaitingLogin` snapshot
|
||||
state and a banner that exposes the verification URI and user code.
|
||||
- Done: `disconnect()` calls `Down`.
|
||||
- Match current daemon address defaults:
|
||||
- Windows: `tcp://127.0.0.1:41731`
|
||||
- Unix-like desktop: `unix:///var/run/netbird.sock`
|
||||
|
||||
3. Settings, profiles, and networks
|
||||
- Done: `GetConfig`/`SetConfig` for the toggleable settings (auto-connect,
|
||||
allow SSH, quantum resistance, lazy connections, block inbound,
|
||||
notifications). Read-only fields (management URL, interface, port, MTU)
|
||||
still need editable forms.
|
||||
- Done: profile add/switch/remove/logout via `AddProfile`,
|
||||
`SwitchProfile`, `RemoveProfile`, `Logout`.
|
||||
- Done: network list with overlap filtering, per-route
|
||||
`SelectNetworks`/`DeselectNetworks`, and exit-node single-selection.
|
||||
|
||||
4. Desktop integration
|
||||
- Done: tray icon and menu via `tray_manager` (status header, profile,
|
||||
Connect/Disconnect, Show window, Quit) with status-aware icons that fall
|
||||
back to template variants on macOS.
|
||||
- Done: window lifecycle via `window_manager` — close hides instead of
|
||||
exiting; tray "Quit" actually destroys the window.
|
||||
- Done: native notifications via `local_notifier`, fed by the daemon's
|
||||
`SubscribeEvents` stream and gated by the `notifications` setting (with
|
||||
CRITICAL severity always firing).
|
||||
- Done: browser launch and clipboard via `Process.run` and
|
||||
`flutter/services` Clipboard.
|
||||
- Remaining: file/folder reveal for debug bundles, single-instance
|
||||
signaling, quick-actions invocation, and sleep/wake forwarding through
|
||||
`NotifyOSLifecycle`. Settings/Networks submenus on the tray are deferred
|
||||
until the window-side flows are stable.
|
||||
- Note: `local_notifier` uses macOS's deprecated `NSUserNotificationCenter`
|
||||
(warns at build time). Plan to swap to `flutter_local_notifications`
|
||||
before release.
|
||||
|
||||
5. Debug and update flows
|
||||
- Done: rich debug bundle screen with anonymize, system-info, upload (URL),
|
||||
and run-with-trace + duration. State machine drives `GetLogLevel` →
|
||||
`SetLogLevel(TRACE)` → `Down` → `SetSyncResponsePersistence` → `Up` →
|
||||
progress over duration → `StopCPUProfile` → `DebugBundle`, with restore
|
||||
of original log level and persistence in a finally. Result dialog covers
|
||||
uploaded, upload-failed, and local-only outcomes with copy/open actions.
|
||||
- Done: enforced-update modal triggered by daemon `progress_window=show`
|
||||
metadata. Polls `GetInstallerResult` with a 15-min timeout, blocks close
|
||||
for 10 s, then surfaces success (auto-close) or failure (error message).
|
||||
- Remaining: hook a "Check for updates" / "Install now" button into the
|
||||
About surface that calls `TriggerUpdate` directly.
|
||||
|
||||
6. Release pipeline
|
||||
- Update `.github/workflows/release.yml` UI build steps.
|
||||
- Update `client/netbird.wxs`, `release_files/install.sh`, and
|
||||
`release_files/ui-post-install.sh` where they assume the Go UI artifact.
|
||||
- Update updater restart behavior in `client/internal/updater/installer`.
|
||||
- Preserve public artifact names until installers and updater logic are
|
||||
intentionally migrated.
|
||||
|
||||
## RPCs Used By The Current UI
|
||||
|
||||
The first production implementation should cover:
|
||||
|
||||
- `Status`, `Up`, `Down`
|
||||
- `Login`, `WaitSSOLogin`, `Logout`
|
||||
- `GetConfig`, `SetConfig`, `GetFeatures`
|
||||
- `SubscribeEvents`
|
||||
- `ListNetworks`, `SelectNetworks`, `DeselectNetworks`
|
||||
- `ListProfiles`, `AddProfile`, `SwitchProfile`, `RemoveProfile`,
|
||||
`GetActiveProfile`
|
||||
- `DebugBundle`, `GetLogLevel`, `SetLogLevel`, `SetSyncResponsePersistence`,
|
||||
`StartCPUProfile`, `StopCPUProfile`
|
||||
- `TriggerUpdate`, `GetInstallerResult`
|
||||
- `NotifyOSLifecycle`
|
||||
|
||||
## Risk Register
|
||||
|
||||
- Desktop tray support differs sharply across Windows, macOS, and Linux.
|
||||
- Linux app indicators and desktop-session startup need distro-level testing.
|
||||
- The updater currently restarts `netbird-ui` by process/app name on Windows and
|
||||
macOS, so artifact naming changes must be coordinated.
|
||||
- Dart gRPC over Unix domain sockets must be validated against the daemon's
|
||||
existing `unix://` address behavior.
|
||||
- Flutter desktop packaging is separate from Go builds, so release CI needs a
|
||||
new toolchain and cache strategy.
|
||||
54
client/flutter_ui/README.md
Normal file
@@ -0,0 +1,54 @@
|
||||
# NetBird Flutter UI
|
||||
|
||||
This is the migration workspace for a Flutter-based replacement for `client/ui`.
|
||||
The existing Go/Fyne UI remains the production UI until this package reaches
|
||||
feature and release-pipeline parity.
|
||||
|
||||
## Scope
|
||||
|
||||
The first target is the desktop UI only. The NetBird daemon, service lifecycle,
|
||||
network engine, and daemon gRPC API stay in Go.
|
||||
|
||||
Initial parity target:
|
||||
|
||||
- tray/menu-bar entry with connection status and connect/disconnect actions
|
||||
- settings and feature flags backed by `DaemonService.GetConfig` and `SetConfig`
|
||||
- profile management
|
||||
- network and exit-node selection
|
||||
- daemon event subscription and desktop notifications
|
||||
- login/session-expired flow
|
||||
- debug bundle flow
|
||||
- enforced-update progress window
|
||||
- Windows, macOS, and Linux packaging integration
|
||||
|
||||
## Bootstrap
|
||||
|
||||
Flutter and Dart are not committed into this repository. After installing the
|
||||
Flutter SDK, run:
|
||||
|
||||
```sh
|
||||
cd client/flutter_ui
|
||||
bash tool/bootstrap.sh
|
||||
bash tool/generate_proto.sh
|
||||
flutter run -d macos -- --daemon-addr=unix:///var/run/netbird.sock
|
||||
```
|
||||
|
||||
Use `-d windows` or `-d linux` on those platforms. The Windows daemon address is
|
||||
currently `tcp://127.0.0.1:41731`.
|
||||
|
||||
For UI-only development without a daemon, run:
|
||||
|
||||
```sh
|
||||
flutter run -d macos -- --fake-daemon
|
||||
```
|
||||
|
||||
## Layout
|
||||
|
||||
- `lib/main.dart`: app entry point and command-line flag parsing
|
||||
- `lib/src/app_shell.dart`: first-pass desktop shell
|
||||
- `lib/src/daemon_client.dart`: daemon boundary with fake and gRPC-backed clients
|
||||
- `lib/src/models.dart`: UI-facing models independent from generated protobufs
|
||||
- `lib/src/generated/`: generated Dart protobuf and gRPC files
|
||||
- `tool/bootstrap.sh`: creates Flutter desktop platform folders once Flutter is installed
|
||||
- `tool/generate_proto.sh`: generates Dart gRPC bindings from `client/proto/daemon.proto`
|
||||
- `MIGRATION.md`: parity plan and release integration checklist
|
||||
10
client/flutter_ui/analysis_options.yaml
Normal file
@@ -0,0 +1,10 @@
|
||||
include: package:lints/recommended.yaml
|
||||
|
||||
analyzer:
|
||||
exclude:
|
||||
- lib/src/generated/**
|
||||
|
||||
linter:
|
||||
rules:
|
||||
avoid_print: true
|
||||
|
||||
BIN
client/flutter_ui/assets/tray/connected-macos.png
Normal file
|
After Width: | Height: | Size: 3.8 KiB |
BIN
client/flutter_ui/assets/tray/connected.png
Normal file
|
After Width: | Height: | Size: 5.2 KiB |
BIN
client/flutter_ui/assets/tray/connecting-macos.png
Normal file
|
After Width: | Height: | Size: 3.8 KiB |
BIN
client/flutter_ui/assets/tray/connecting.png
Normal file
|
After Width: | Height: | Size: 5.3 KiB |
BIN
client/flutter_ui/assets/tray/disconnected-macos.png
Normal file
|
After Width: | Height: | Size: 3.4 KiB |
BIN
client/flutter_ui/assets/tray/disconnected.png
Normal file
|
After Width: | Height: | Size: 4.7 KiB |
BIN
client/flutter_ui/assets/tray/error-macos.png
Normal file
|
After Width: | Height: | Size: 3.7 KiB |
BIN
client/flutter_ui/assets/tray/error.png
Normal file
|
After Width: | Height: | Size: 5.1 KiB |
53
client/flutter_ui/lib/main.dart
Normal file
@@ -0,0 +1,53 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:window_manager/window_manager.dart';
|
||||
|
||||
import 'src/app_shell.dart';
|
||||
import 'src/daemon_client.dart';
|
||||
import 'src/desktop_integration.dart';
|
||||
|
||||
Future<void> main(List<String> args) async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
final daemonAddr = _readFlag(args, 'daemon-addr') ?? _defaultDaemonAddr();
|
||||
final fakeDaemon = args.contains('--fake-daemon');
|
||||
|
||||
await windowManager.ensureInitialized();
|
||||
const windowOptions = WindowOptions(
|
||||
size: Size(900, 640),
|
||||
minimumSize: Size(720, 520),
|
||||
center: true,
|
||||
title: 'NetBird',
|
||||
);
|
||||
await windowManager.waitUntilReadyToShow(windowOptions, () async {
|
||||
await windowManager.show();
|
||||
await windowManager.focus();
|
||||
});
|
||||
|
||||
final client = fakeDaemon
|
||||
? FakeDaemonClient(daemonAddr: daemonAddr)
|
||||
: GrpcDaemonClient(daemonAddr: daemonAddr);
|
||||
|
||||
final integration = DesktopIntegration(client: client);
|
||||
await integration.initialize();
|
||||
|
||||
runApp(NetBirdFlutterApp(client: client, integration: integration));
|
||||
}
|
||||
|
||||
String? _readFlag(List<String> args, String name) {
|
||||
final prefix = '--$name=';
|
||||
for (final arg in args) {
|
||||
if (arg.startsWith(prefix)) {
|
||||
return arg.substring(prefix.length);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
String _defaultDaemonAddr() {
|
||||
if (Platform.isWindows) {
|
||||
return 'tcp://127.0.0.1:41731';
|
||||
}
|
||||
return 'unix:///var/run/netbird.sock';
|
||||
}
|
||||
889
client/flutter_ui/lib/src/app_shell.dart
Normal file
@@ -0,0 +1,889 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import 'daemon_client.dart';
|
||||
import 'debug_screen.dart';
|
||||
import 'desktop_integration.dart';
|
||||
import 'models.dart';
|
||||
import 'platform.dart';
|
||||
import 'update_progress.dart';
|
||||
|
||||
class NetBirdFlutterApp extends StatelessWidget {
|
||||
const NetBirdFlutterApp({required this.client, this.integration, super.key});
|
||||
|
||||
final DaemonClient client;
|
||||
final DesktopIntegration? integration;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MaterialApp(
|
||||
title: 'NetBird',
|
||||
debugShowCheckedModeBanner: false,
|
||||
theme: ThemeData(
|
||||
useMaterial3: true,
|
||||
colorSchemeSeed: const Color(0xFF008C95),
|
||||
brightness: Brightness.light,
|
||||
),
|
||||
darkTheme: ThemeData(
|
||||
useMaterial3: true,
|
||||
colorSchemeSeed: const Color(0xFF008C95),
|
||||
brightness: Brightness.dark,
|
||||
),
|
||||
home: AppShell(client: client, integration: integration),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class AppShell extends StatefulWidget {
|
||||
const AppShell({required this.client, this.integration, super.key});
|
||||
|
||||
final DaemonClient client;
|
||||
final DesktopIntegration? integration;
|
||||
|
||||
@override
|
||||
State<AppShell> createState() => _AppShellState();
|
||||
}
|
||||
|
||||
class _AppShellState extends State<AppShell> {
|
||||
late ClientSnapshot _snapshot;
|
||||
StreamSubscription<ClientSnapshot>? _subscription;
|
||||
StreamSubscription<UpdateProgressEvent>? _updateSubscription;
|
||||
StreamSubscription<int>? _tabSubscription;
|
||||
int _selectedIndex = 0;
|
||||
bool _busy = false;
|
||||
bool _updateDialogOpen = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_snapshot = ClientSnapshot.initial(widget.client.daemonAddr);
|
||||
_subscription = widget.client.watchSnapshot().listen((snapshot) {
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
setState(() => _snapshot = snapshot);
|
||||
});
|
||||
_updateSubscription = widget.client.watchUpdateRequests().listen(
|
||||
_showUpdateDialog,
|
||||
);
|
||||
_tabSubscription = widget.integration?.tabRequests.listen((index) {
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
setState(() => _selectedIndex = index);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_subscription?.cancel();
|
||||
_updateSubscription?.cancel();
|
||||
_tabSubscription?.cancel();
|
||||
widget.client.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _showUpdateDialog(UpdateProgressEvent event) async {
|
||||
if (!mounted || _updateDialogOpen) {
|
||||
return;
|
||||
}
|
||||
_updateDialogOpen = true;
|
||||
try {
|
||||
await showUpdateProgressDialog(
|
||||
context: context,
|
||||
client: widget.client,
|
||||
event: event,
|
||||
);
|
||||
} finally {
|
||||
if (mounted) {
|
||||
_updateDialogOpen = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: Row(
|
||||
children: [
|
||||
NavigationRail(
|
||||
selectedIndex: _selectedIndex,
|
||||
onDestinationSelected: (index) {
|
||||
setState(() => _selectedIndex = index);
|
||||
},
|
||||
labelType: NavigationRailLabelType.all,
|
||||
leading: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
child: _StatusGlyph(status: _snapshot.status),
|
||||
),
|
||||
destinations: const [
|
||||
NavigationRailDestination(
|
||||
icon: Icon(Icons.hub_outlined),
|
||||
selectedIcon: Icon(Icons.hub),
|
||||
label: Text('Status'),
|
||||
),
|
||||
NavigationRailDestination(
|
||||
icon: Icon(Icons.route_outlined),
|
||||
selectedIcon: Icon(Icons.route),
|
||||
label: Text('Networks'),
|
||||
),
|
||||
NavigationRailDestination(
|
||||
icon: Icon(Icons.account_circle_outlined),
|
||||
selectedIcon: Icon(Icons.account_circle),
|
||||
label: Text('Profiles'),
|
||||
),
|
||||
NavigationRailDestination(
|
||||
icon: Icon(Icons.tune_outlined),
|
||||
selectedIcon: Icon(Icons.tune),
|
||||
label: Text('Settings'),
|
||||
),
|
||||
NavigationRailDestination(
|
||||
icon: Icon(Icons.bug_report_outlined),
|
||||
selectedIcon: Icon(Icons.bug_report),
|
||||
label: Text('Debug'),
|
||||
),
|
||||
],
|
||||
),
|
||||
const VerticalDivider(width: 1),
|
||||
Expanded(child: SafeArea(child: _buildPage(context))),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPage(BuildContext context) {
|
||||
return switch (_selectedIndex) {
|
||||
0 => _StatusPane(
|
||||
snapshot: _snapshot,
|
||||
busy: _busy,
|
||||
onConnect: () => _run(widget.client.connect),
|
||||
onDisconnect: () => _run(widget.client.disconnect),
|
||||
),
|
||||
1 => _NetworksPane(snapshot: _snapshot, client: widget.client),
|
||||
2 => _ProfilesPane(snapshot: _snapshot, client: widget.client),
|
||||
3 => _SettingsPane(snapshot: _snapshot, client: widget.client),
|
||||
_ => DebugScreen(client: widget.client),
|
||||
};
|
||||
}
|
||||
|
||||
Future<void> _run(Future<void> Function() action) async {
|
||||
if (_busy) {
|
||||
return;
|
||||
}
|
||||
setState(() => _busy = true);
|
||||
try {
|
||||
await action();
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() => _busy = false);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _Page extends StatelessWidget {
|
||||
const _Page({required this.title, required this.child, this.actions});
|
||||
|
||||
final String title;
|
||||
final Widget child;
|
||||
final List<Widget>? actions;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
title,
|
||||
style: Theme.of(context).textTheme.headlineSmall,
|
||||
),
|
||||
),
|
||||
if (actions != null) ...actions!,
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
Expanded(child: child),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _StatusPane extends StatelessWidget {
|
||||
const _StatusPane({
|
||||
required this.snapshot,
|
||||
required this.busy,
|
||||
required this.onConnect,
|
||||
required this.onDisconnect,
|
||||
});
|
||||
|
||||
final ClientSnapshot snapshot;
|
||||
final bool busy;
|
||||
final VoidCallback onConnect;
|
||||
final VoidCallback onDisconnect;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final connected = snapshot.status == ConnectionStatus.connected;
|
||||
final connecting =
|
||||
snapshot.status == ConnectionStatus.connecting ||
|
||||
snapshot.status == ConnectionStatus.awaitingLogin;
|
||||
|
||||
return _Page(
|
||||
title: 'Status',
|
||||
child: ListView(
|
||||
children: [
|
||||
_InfoRow(label: 'Connection', value: snapshot.status.label),
|
||||
_InfoRow(label: 'Daemon', value: snapshot.daemonAddr),
|
||||
_InfoRow(label: 'Daemon version', value: snapshot.daemonVersion),
|
||||
if (snapshot.pendingLogin != null) ...[
|
||||
const SizedBox(height: 16),
|
||||
_LoginBanner(pending: snapshot.pendingLogin!),
|
||||
],
|
||||
if (snapshot.errorMessage != null) ...[
|
||||
const SizedBox(height: 16),
|
||||
_ErrorBanner(message: snapshot.errorMessage!),
|
||||
],
|
||||
const SizedBox(height: 24),
|
||||
Wrap(
|
||||
spacing: 12,
|
||||
runSpacing: 12,
|
||||
children: [
|
||||
FilledButton.icon(
|
||||
onPressed: busy || connected || connecting ? null : onConnect,
|
||||
icon: const Icon(Icons.power_settings_new),
|
||||
label: const Text('Connect'),
|
||||
),
|
||||
OutlinedButton.icon(
|
||||
onPressed: busy || !connected ? null : onDisconnect,
|
||||
icon: const Icon(Icons.power_off),
|
||||
label: const Text('Disconnect'),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
_SectionLabel('Active profile'),
|
||||
_ProfileTile(profile: snapshot.activeProfile),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _NetworksPane extends StatefulWidget {
|
||||
const _NetworksPane({required this.snapshot, required this.client});
|
||||
|
||||
final ClientSnapshot snapshot;
|
||||
final DaemonClient client;
|
||||
|
||||
@override
|
||||
State<_NetworksPane> createState() => _NetworksPaneState();
|
||||
}
|
||||
|
||||
class _NetworksPaneState extends State<_NetworksPane> {
|
||||
NetworkFilter _filter = NetworkFilter.all;
|
||||
final Set<String> _busyRoutes = {};
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final networks = widget.snapshot.networks
|
||||
.where(_filter.matches)
|
||||
.toList();
|
||||
|
||||
return _Page(
|
||||
title: 'Networks',
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SegmentedButton<NetworkFilter>(
|
||||
segments: const [
|
||||
ButtonSegment(
|
||||
value: NetworkFilter.all,
|
||||
icon: Icon(Icons.all_inclusive),
|
||||
label: Text('All'),
|
||||
),
|
||||
ButtonSegment(
|
||||
value: NetworkFilter.overlapping,
|
||||
icon: Icon(Icons.compare_arrows),
|
||||
label: Text('Overlapping'),
|
||||
),
|
||||
ButtonSegment(
|
||||
value: NetworkFilter.exitNode,
|
||||
icon: Icon(Icons.public),
|
||||
label: Text('Exit nodes'),
|
||||
),
|
||||
],
|
||||
selected: {_filter},
|
||||
onSelectionChanged: (selected) {
|
||||
setState(() => _filter = selected.single);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
if (networks.isEmpty)
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 24),
|
||||
child: Text('No networks to show.'),
|
||||
)
|
||||
else
|
||||
Expanded(
|
||||
child: ListView.separated(
|
||||
itemCount: networks.length,
|
||||
separatorBuilder: (_, _) => const Divider(height: 1),
|
||||
itemBuilder: (context, index) {
|
||||
final route = networks[index];
|
||||
final exitNodeMode = _filter == NetworkFilter.exitNode;
|
||||
return _NetworkTile(
|
||||
route: route,
|
||||
exitNodeMode: exitNodeMode,
|
||||
busy: _busyRoutes.contains(route.id),
|
||||
onChanged: (selected) =>
|
||||
_toggle(route, selected, exitNodeMode),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _toggle(
|
||||
NetworkRoute route,
|
||||
bool selected,
|
||||
bool exitNodeMode,
|
||||
) async {
|
||||
if (_busyRoutes.contains(route.id)) {
|
||||
return;
|
||||
}
|
||||
setState(() => _busyRoutes.add(route.id));
|
||||
try {
|
||||
if (exitNodeMode) {
|
||||
await widget.client.setExitNode(selected ? route.id : null);
|
||||
} else {
|
||||
await widget.client.setNetworkSelection(route.id, selected);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() => _busyRoutes.remove(route.id));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _ProfilesPane extends StatefulWidget {
|
||||
const _ProfilesPane({required this.snapshot, required this.client});
|
||||
|
||||
final ClientSnapshot snapshot;
|
||||
final DaemonClient client;
|
||||
|
||||
@override
|
||||
State<_ProfilesPane> createState() => _ProfilesPaneState();
|
||||
}
|
||||
|
||||
class _ProfilesPaneState extends State<_ProfilesPane> {
|
||||
bool _busy = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return _Page(
|
||||
title: 'Profiles',
|
||||
actions: [
|
||||
FilledButton.tonalIcon(
|
||||
onPressed: _busy ? null : _showAddDialog,
|
||||
icon: const Icon(Icons.add),
|
||||
label: const Text('Add profile'),
|
||||
),
|
||||
],
|
||||
child: ListView.separated(
|
||||
itemCount: widget.snapshot.profiles.length,
|
||||
separatorBuilder: (_, _) => const Divider(height: 1),
|
||||
itemBuilder: (context, index) {
|
||||
final profile = widget.snapshot.profiles[index];
|
||||
return _ProfileTile(
|
||||
profile: profile,
|
||||
onTap: profile.active || _busy ? null : () => _confirmSwitch(profile),
|
||||
trailing: _profileMenu(profile),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _profileMenu(ProfileInfo profile) {
|
||||
return PopupMenuButton<_ProfileAction>(
|
||||
enabled: !_busy,
|
||||
onSelected: (action) => _handleAction(action, profile),
|
||||
itemBuilder: (context) => [
|
||||
if (profile.active)
|
||||
const PopupMenuItem(
|
||||
value: _ProfileAction.logout,
|
||||
child: ListTile(
|
||||
leading: Icon(Icons.logout),
|
||||
title: Text('Logout'),
|
||||
contentPadding: EdgeInsets.zero,
|
||||
),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: _ProfileAction.remove,
|
||||
enabled: !profile.active,
|
||||
child: const ListTile(
|
||||
leading: Icon(Icons.delete_outline),
|
||||
title: Text('Remove'),
|
||||
contentPadding: EdgeInsets.zero,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _handleAction(
|
||||
_ProfileAction action,
|
||||
ProfileInfo profile,
|
||||
) async {
|
||||
switch (action) {
|
||||
case _ProfileAction.logout:
|
||||
await _confirmAndRun(
|
||||
title: 'Logout from ${profile.name}?',
|
||||
message:
|
||||
'This disconnects the active profile and clears its session.',
|
||||
run: widget.client.logoutActive,
|
||||
);
|
||||
case _ProfileAction.remove:
|
||||
await _confirmAndRun(
|
||||
title: 'Remove profile ${profile.name}?',
|
||||
message: 'This deletes the profile from this device.',
|
||||
run: () => widget.client.removeProfile(profile.name),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _confirmSwitch(ProfileInfo profile) async {
|
||||
await _confirmAndRun(
|
||||
title: 'Switch to ${profile.name}?',
|
||||
message: 'The connection will restart with the new profile.',
|
||||
run: () => widget.client.switchProfile(profile.name),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _showAddDialog() async {
|
||||
final controller = TextEditingController();
|
||||
final name = await showDialog<String>(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
return AlertDialog(
|
||||
title: const Text('Add profile'),
|
||||
content: TextField(
|
||||
controller: controller,
|
||||
autofocus: true,
|
||||
decoration: const InputDecoration(labelText: 'Profile name'),
|
||||
onSubmitted: (value) => Navigator.of(context).pop(value.trim()),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () =>
|
||||
Navigator.of(context).pop(controller.text.trim()),
|
||||
child: const Text('Add'),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
if (name == null || name.isEmpty) {
|
||||
return;
|
||||
}
|
||||
await _runBusy(() => widget.client.addProfile(name));
|
||||
}
|
||||
|
||||
Future<void> _confirmAndRun({
|
||||
required String title,
|
||||
required String message,
|
||||
required Future<void> Function() run,
|
||||
}) async {
|
||||
final confirm = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
return AlertDialog(
|
||||
title: Text(title),
|
||||
content: Text(message),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(false),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.of(context).pop(true),
|
||||
child: const Text('Confirm'),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
if (confirm != true) {
|
||||
return;
|
||||
}
|
||||
await _runBusy(run);
|
||||
}
|
||||
|
||||
Future<void> _runBusy(Future<void> Function() action) async {
|
||||
if (_busy) {
|
||||
return;
|
||||
}
|
||||
setState(() => _busy = true);
|
||||
try {
|
||||
await action();
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() => _busy = false);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum _ProfileAction { logout, remove }
|
||||
|
||||
class _SettingsPane extends StatefulWidget {
|
||||
const _SettingsPane({required this.snapshot, required this.client});
|
||||
|
||||
final ClientSnapshot snapshot;
|
||||
final DaemonClient client;
|
||||
|
||||
@override
|
||||
State<_SettingsPane> createState() => _SettingsPaneState();
|
||||
}
|
||||
|
||||
class _SettingsPaneState extends State<_SettingsPane> {
|
||||
bool _writing = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final settings = widget.snapshot.settings;
|
||||
final disabled = _writing;
|
||||
|
||||
return _Page(
|
||||
title: 'Settings',
|
||||
child: ListView(
|
||||
children: [
|
||||
_InfoRow(label: 'Management URL', value: settings.managementUrl),
|
||||
_InfoRow(label: 'Interface', value: settings.interfaceName),
|
||||
_InfoRow(label: 'WireGuard port', value: '${settings.wireguardPort}'),
|
||||
_InfoRow(label: 'MTU', value: '${settings.mtu}'),
|
||||
const SizedBox(height: 16),
|
||||
SwitchListTile(
|
||||
value: settings.autoConnect,
|
||||
onChanged: disabled
|
||||
? null
|
||||
: (value) =>
|
||||
_apply(settings.copyWith(autoConnect: value)),
|
||||
title: const Text('Connect on startup'),
|
||||
),
|
||||
SwitchListTile(
|
||||
value: settings.allowSsh,
|
||||
onChanged: disabled
|
||||
? null
|
||||
: (value) => _apply(settings.copyWith(allowSsh: value)),
|
||||
title: const Text('Allow SSH'),
|
||||
),
|
||||
SwitchListTile(
|
||||
value: settings.quantumResistance,
|
||||
onChanged: disabled
|
||||
? null
|
||||
: (value) =>
|
||||
_apply(settings.copyWith(quantumResistance: value)),
|
||||
title: const Text('Quantum resistance'),
|
||||
),
|
||||
SwitchListTile(
|
||||
value: settings.lazyConnection,
|
||||
onChanged: disabled
|
||||
? null
|
||||
: (value) =>
|
||||
_apply(settings.copyWith(lazyConnection: value)),
|
||||
title: const Text('Lazy connections'),
|
||||
),
|
||||
SwitchListTile(
|
||||
value: settings.blockInbound,
|
||||
onChanged: disabled
|
||||
? null
|
||||
: (value) =>
|
||||
_apply(settings.copyWith(blockInbound: value)),
|
||||
title: const Text('Block inbound'),
|
||||
),
|
||||
SwitchListTile(
|
||||
value: settings.notifications,
|
||||
onChanged: disabled
|
||||
? null
|
||||
: (value) =>
|
||||
_apply(settings.copyWith(notifications: value)),
|
||||
title: const Text('Notifications'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _apply(ClientSettings updated) async {
|
||||
setState(() => _writing = true);
|
||||
try {
|
||||
await widget.client.updateSettings(updated);
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() => _writing = false);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _StatusGlyph extends StatelessWidget {
|
||||
const _StatusGlyph({required this.status});
|
||||
|
||||
final ConnectionStatus status;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final color = switch (status) {
|
||||
ConnectionStatus.connected => Colors.green,
|
||||
ConnectionStatus.connecting => Colors.amber,
|
||||
ConnectionStatus.awaitingLogin => Colors.lightBlue,
|
||||
ConnectionStatus.error => Colors.red,
|
||||
ConnectionStatus.disconnected => Colors.grey,
|
||||
};
|
||||
|
||||
return Tooltip(
|
||||
message: status.label,
|
||||
child: Icon(Icons.circle, color: color, size: 18),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _InfoRow extends StatelessWidget {
|
||||
const _InfoRow({required this.label, required this.value});
|
||||
|
||||
final String label;
|
||||
final String value;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 160,
|
||||
child: Text(label, style: Theme.of(context).textTheme.labelLarge),
|
||||
),
|
||||
Expanded(child: Text(value)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SectionLabel extends StatelessWidget {
|
||||
const _SectionLabel(this.text);
|
||||
|
||||
final String text;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: Text(text, style: Theme.of(context).textTheme.titleMedium),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ErrorBanner extends StatelessWidget {
|
||||
const _ErrorBanner({required this.message});
|
||||
|
||||
final String message;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colors = Theme.of(context).colorScheme;
|
||||
return DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: colors.errorContainer,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.error_outline, color: colors.onErrorContainer),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
message,
|
||||
style: TextStyle(color: colors.onErrorContainer),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _LoginBanner extends StatelessWidget {
|
||||
const _LoginBanner({required this.pending});
|
||||
|
||||
final PendingLogin pending;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colors = Theme.of(context).colorScheme;
|
||||
return DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: colors.tertiaryContainer,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Sign in to continue',
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
color: colors.onTertiaryContainer,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'A browser window opened to complete sign-in. '
|
||||
'If it did not, open the URL below.',
|
||||
style: TextStyle(color: colors.onTertiaryContainer),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
SelectableText(
|
||||
pending.verificationUri,
|
||||
style: TextStyle(color: colors.onTertiaryContainer),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'Code: ${pending.userCode}',
|
||||
style: TextStyle(color: colors.onTertiaryContainer),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
children: [
|
||||
FilledButton.tonalIcon(
|
||||
onPressed: () => _openUrl(pending.verificationUri),
|
||||
icon: const Icon(Icons.open_in_new),
|
||||
label: const Text('Open in browser'),
|
||||
),
|
||||
OutlinedButton.icon(
|
||||
onPressed: () => _copy(context, pending.verificationUri),
|
||||
icon: const Icon(Icons.copy),
|
||||
label: const Text('Copy URL'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _openUrl(String url) async {
|
||||
await openExternalUrl(url);
|
||||
}
|
||||
|
||||
Future<void> _copy(BuildContext context, String url) async {
|
||||
await Clipboard.setData(ClipboardData(text: url));
|
||||
if (!context.mounted) {
|
||||
return;
|
||||
}
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('URL copied')),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _NetworkTile extends StatelessWidget {
|
||||
const _NetworkTile({
|
||||
required this.route,
|
||||
required this.exitNodeMode,
|
||||
required this.busy,
|
||||
required this.onChanged,
|
||||
});
|
||||
|
||||
final NetworkRoute route;
|
||||
final bool exitNodeMode;
|
||||
final bool busy;
|
||||
final ValueChanged<bool> onChanged;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final subtitle = [
|
||||
route.range,
|
||||
if (route.domains.isNotEmpty) route.domains.join(', '),
|
||||
].join(' ');
|
||||
|
||||
Widget leading;
|
||||
if (busy) {
|
||||
leading = const SizedBox(
|
||||
width: 24,
|
||||
height: 24,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
);
|
||||
} else if (exitNodeMode) {
|
||||
leading = IconButton(
|
||||
icon: Icon(
|
||||
route.selected
|
||||
? Icons.radio_button_checked
|
||||
: Icons.radio_button_unchecked,
|
||||
),
|
||||
onPressed: () => onChanged(!route.selected),
|
||||
);
|
||||
} else {
|
||||
leading = Checkbox(
|
||||
value: route.selected,
|
||||
onChanged: (value) => onChanged(value ?? false),
|
||||
);
|
||||
}
|
||||
|
||||
return ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: leading,
|
||||
title: Text(route.id),
|
||||
subtitle: Text(subtitle),
|
||||
trailing: route.isExitNode ? const Icon(Icons.public) : null,
|
||||
onTap: busy ? null : () => onChanged(!route.selected),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ProfileTile extends StatelessWidget {
|
||||
const _ProfileTile({required this.profile, this.onTap, this.trailing});
|
||||
|
||||
final ProfileInfo profile;
|
||||
final VoidCallback? onTap;
|
||||
final Widget? trailing;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: Icon(
|
||||
profile.active ? Icons.check_circle : Icons.circle_outlined,
|
||||
),
|
||||
title: Text(profile.name),
|
||||
subtitle: profile.email == null ? null : Text(profile.email!),
|
||||
onTap: onTap,
|
||||
trailing: trailing,
|
||||
);
|
||||
}
|
||||
}
|
||||
916
client/flutter_ui/lib/src/daemon_client.dart
Normal file
@@ -0,0 +1,916 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:grpc/grpc.dart';
|
||||
|
||||
import 'generated/daemon.pbgrpc.dart' as daemon;
|
||||
import 'models.dart';
|
||||
import 'platform.dart';
|
||||
|
||||
const _userAgent = 'netbird-desktop-ui/development';
|
||||
|
||||
abstract class DaemonClient {
|
||||
String get daemonAddr;
|
||||
|
||||
Stream<ClientSnapshot> watchSnapshot();
|
||||
|
||||
Stream<SystemNotification> watchEvents();
|
||||
|
||||
Stream<UpdateProgressEvent> watchUpdateRequests();
|
||||
|
||||
Future<void> connect();
|
||||
|
||||
Future<void> disconnect();
|
||||
|
||||
Future<void> bringUp();
|
||||
|
||||
Future<void> bringDown();
|
||||
|
||||
Future<DebugBundleResult> debugBundle({
|
||||
required bool anonymize,
|
||||
required bool systemInfo,
|
||||
String? uploadUrl,
|
||||
});
|
||||
|
||||
Future<DaemonLogLevel> getLogLevel();
|
||||
|
||||
Future<void> setLogLevel(DaemonLogLevel level);
|
||||
|
||||
Future<void> setSyncResponsePersistence(bool enabled);
|
||||
|
||||
Future<void> startCpuProfile();
|
||||
|
||||
Future<void> stopCpuProfile();
|
||||
|
||||
Future<TriggerUpdateResult> triggerUpdate();
|
||||
|
||||
Future<InstallerResult> getInstallerResult();
|
||||
|
||||
Future<void> updateSettings(ClientSettings updated);
|
||||
|
||||
Future<void> setNetworkSelection(String routeId, bool selected);
|
||||
|
||||
Future<void> setExitNode(String? routeId);
|
||||
|
||||
Future<void> switchProfile(String name);
|
||||
|
||||
Future<void> addProfile(String name);
|
||||
|
||||
Future<void> removeProfile(String name);
|
||||
|
||||
Future<void> logoutActive();
|
||||
|
||||
void dispose();
|
||||
}
|
||||
|
||||
class GrpcDaemonClient implements DaemonClient {
|
||||
GrpcDaemonClient({required this.daemonAddr}) {
|
||||
_snapshot = ClientSnapshot.initial(daemonAddr);
|
||||
_channel = _createChannel(daemonAddr);
|
||||
_client = daemon.DaemonServiceClient(_channel);
|
||||
}
|
||||
|
||||
@override
|
||||
final String daemonAddr;
|
||||
|
||||
final _snapshots = StreamController<ClientSnapshot>.broadcast();
|
||||
final _events = StreamController<SystemNotification>.broadcast();
|
||||
final _updateRequests = StreamController<UpdateProgressEvent>.broadcast();
|
||||
final _refreshInterval = const Duration(seconds: 2);
|
||||
final _callTimeout = const Duration(seconds: 5);
|
||||
final _ssoLoginTimeout = const Duration(minutes: 5);
|
||||
final _installerPollTimeout = const Duration(minutes: 15);
|
||||
|
||||
late final ClientChannel _channel;
|
||||
late final daemon.DaemonServiceClient _client;
|
||||
late ClientSnapshot _snapshot;
|
||||
|
||||
Timer? _poller;
|
||||
StreamSubscription<daemon.SystemEvent>? _eventSubscription;
|
||||
var _started = false;
|
||||
|
||||
@override
|
||||
Stream<ClientSnapshot> watchSnapshot() {
|
||||
_start();
|
||||
scheduleMicrotask(_emit);
|
||||
return _snapshots.stream;
|
||||
}
|
||||
|
||||
@override
|
||||
Stream<SystemNotification> watchEvents() {
|
||||
_start();
|
||||
return _events.stream;
|
||||
}
|
||||
|
||||
@override
|
||||
Stream<UpdateProgressEvent> watchUpdateRequests() {
|
||||
_start();
|
||||
return _updateRequests.stream;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> connect() async {
|
||||
_setStatus(ConnectionStatus.connecting, clearError: true);
|
||||
try {
|
||||
await _runLoginFlow();
|
||||
await _client.up(
|
||||
daemon.UpRequest(username: _username()),
|
||||
options: _options(timeout: const Duration(seconds: 30)),
|
||||
);
|
||||
} catch (error) {
|
||||
_snapshot = _snapshot.copyWith(
|
||||
status: ConnectionStatus.error,
|
||||
errorMessage: _formatError(error),
|
||||
clearPendingLogin: true,
|
||||
);
|
||||
_emit();
|
||||
return;
|
||||
} finally {
|
||||
await _refresh();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> disconnect() async {
|
||||
await _runRpc(() async {
|
||||
await _client.down(daemon.DownRequest(), options: _options());
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> bringUp() async {
|
||||
await _client.up(
|
||||
daemon.UpRequest(username: _username()),
|
||||
options: _options(timeout: const Duration(seconds: 30)),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> bringDown() async {
|
||||
await _client.down(
|
||||
daemon.DownRequest(),
|
||||
options: _options(timeout: const Duration(seconds: 15)),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<DebugBundleResult> debugBundle({
|
||||
required bool anonymize,
|
||||
required bool systemInfo,
|
||||
String? uploadUrl,
|
||||
}) async {
|
||||
final request = daemon.DebugBundleRequest(
|
||||
anonymize: anonymize,
|
||||
systemInfo: systemInfo,
|
||||
uploadURL: uploadUrl ?? '',
|
||||
);
|
||||
final response = await _client.debugBundle(
|
||||
request,
|
||||
options: _options(timeout: const Duration(minutes: 2)),
|
||||
);
|
||||
return DebugBundleResult(
|
||||
path: response.path,
|
||||
uploadedKey: response.uploadedKey,
|
||||
uploadFailureReason: response.uploadFailureReason,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<DaemonLogLevel> getLogLevel() async {
|
||||
final response = await _client.getLogLevel(
|
||||
daemon.GetLogLevelRequest(),
|
||||
options: _options(),
|
||||
);
|
||||
return _mapLogLevelFromProto(response.level);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> setLogLevel(DaemonLogLevel level) async {
|
||||
await _client.setLogLevel(
|
||||
daemon.SetLogLevelRequest(level: _mapLogLevelToProto(level)),
|
||||
options: _options(),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> setSyncResponsePersistence(bool enabled) async {
|
||||
await _client.setSyncResponsePersistence(
|
||||
daemon.SetSyncResponsePersistenceRequest(enabled: enabled),
|
||||
options: _options(),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> startCpuProfile() async {
|
||||
await _client.startCPUProfile(
|
||||
daemon.StartCPUProfileRequest(),
|
||||
options: _options(),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> stopCpuProfile() async {
|
||||
await _client.stopCPUProfile(
|
||||
daemon.StopCPUProfileRequest(),
|
||||
options: _options(),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<TriggerUpdateResult> triggerUpdate() async {
|
||||
final response = await _client.triggerUpdate(
|
||||
daemon.TriggerUpdateRequest(),
|
||||
options: _options(timeout: const Duration(seconds: 30)),
|
||||
);
|
||||
return TriggerUpdateResult(
|
||||
success: response.success,
|
||||
errorMessage: response.errorMsg,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<InstallerResult> getInstallerResult() async {
|
||||
final response = await _client.getInstallerResult(
|
||||
daemon.InstallerResultRequest(),
|
||||
options: _options(timeout: _installerPollTimeout),
|
||||
);
|
||||
return InstallerResult(
|
||||
success: response.success,
|
||||
errorMessage: response.errorMsg,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> updateSettings(ClientSettings updated) async {
|
||||
await _runRpc(() async {
|
||||
final activeProfile = _snapshot.activeProfile.name;
|
||||
await _client.setConfig(
|
||||
daemon.SetConfigRequest(
|
||||
username: _username(),
|
||||
profileName: activeProfile,
|
||||
managementUrl: updated.managementUrl,
|
||||
rosenpassEnabled: updated.quantumResistance,
|
||||
serverSSHAllowed: updated.allowSsh,
|
||||
disableAutoConnect: !updated.autoConnect,
|
||||
disableNotifications: !updated.notifications,
|
||||
lazyConnectionEnabled: updated.lazyConnection,
|
||||
blockInbound: updated.blockInbound,
|
||||
),
|
||||
options: _options(timeout: const Duration(seconds: 10)),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> setNetworkSelection(String routeId, bool selected) async {
|
||||
await _runRpc(() async {
|
||||
final request = daemon.SelectNetworksRequest(networkIDs: [routeId]);
|
||||
if (selected) {
|
||||
await _client.selectNetworks(request, options: _options());
|
||||
} else {
|
||||
await _client.deselectNetworks(request, options: _options());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> setExitNode(String? routeId) async {
|
||||
await _runRpc(() async {
|
||||
final exitNodeIds = _snapshot.networks
|
||||
.where((route) => route.isExitNode)
|
||||
.map((route) => route.id)
|
||||
.toList();
|
||||
if (exitNodeIds.isNotEmpty) {
|
||||
await _client.deselectNetworks(
|
||||
daemon.SelectNetworksRequest(networkIDs: exitNodeIds),
|
||||
options: _options(),
|
||||
);
|
||||
}
|
||||
if (routeId != null) {
|
||||
await _client.selectNetworks(
|
||||
daemon.SelectNetworksRequest(networkIDs: [routeId]),
|
||||
options: _options(),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> switchProfile(String name) async {
|
||||
await _runRpc(() async {
|
||||
await _client.switchProfile(
|
||||
daemon.SwitchProfileRequest(profileName: name, username: _username()),
|
||||
options: _options(),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> addProfile(String name) async {
|
||||
await _runRpc(() async {
|
||||
await _client.addProfile(
|
||||
daemon.AddProfileRequest(profileName: name, username: _username()),
|
||||
options: _options(),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> removeProfile(String name) async {
|
||||
await _runRpc(() async {
|
||||
await _client.removeProfile(
|
||||
daemon.RemoveProfileRequest(profileName: name, username: _username()),
|
||||
options: _options(),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> logoutActive() async {
|
||||
await _runRpc(() async {
|
||||
final active = _snapshot.activeProfile.name;
|
||||
await _client.logout(
|
||||
daemon.LogoutRequest(profileName: active, username: _username()),
|
||||
options: _options(timeout: const Duration(seconds: 15)),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_poller?.cancel();
|
||||
unawaited(_eventSubscription?.cancel() ?? Future<void>.value());
|
||||
_events.close();
|
||||
_updateRequests.close();
|
||||
_snapshots.close();
|
||||
unawaited(_channel.shutdown());
|
||||
}
|
||||
|
||||
void _start() {
|
||||
if (_started) {
|
||||
return;
|
||||
}
|
||||
_started = true;
|
||||
unawaited(_refresh());
|
||||
_poller = Timer.periodic(_refreshInterval, (_) {
|
||||
unawaited(_refresh());
|
||||
});
|
||||
_eventSubscription = _client
|
||||
.subscribeEvents(daemon.SubscribeRequest(), options: _options())
|
||||
.listen(
|
||||
(event) {
|
||||
_checkUpdateMetadata(event);
|
||||
final notification = _mapSystemEvent(event);
|
||||
if (notification != null && !_events.isClosed) {
|
||||
_events.add(notification);
|
||||
}
|
||||
unawaited(_refresh());
|
||||
},
|
||||
onError: (_) {},
|
||||
);
|
||||
}
|
||||
|
||||
DaemonLogLevel _mapLogLevelFromProto(daemon.LogLevel level) {
|
||||
return switch (level) {
|
||||
daemon.LogLevel.PANIC => DaemonLogLevel.panic,
|
||||
daemon.LogLevel.FATAL => DaemonLogLevel.fatal,
|
||||
daemon.LogLevel.ERROR => DaemonLogLevel.error,
|
||||
daemon.LogLevel.WARN => DaemonLogLevel.warn,
|
||||
daemon.LogLevel.INFO => DaemonLogLevel.info,
|
||||
daemon.LogLevel.DEBUG => DaemonLogLevel.debug,
|
||||
daemon.LogLevel.TRACE => DaemonLogLevel.trace,
|
||||
_ => DaemonLogLevel.unknown,
|
||||
};
|
||||
}
|
||||
|
||||
daemon.LogLevel _mapLogLevelToProto(DaemonLogLevel level) {
|
||||
return switch (level) {
|
||||
DaemonLogLevel.panic => daemon.LogLevel.PANIC,
|
||||
DaemonLogLevel.fatal => daemon.LogLevel.FATAL,
|
||||
DaemonLogLevel.error => daemon.LogLevel.ERROR,
|
||||
DaemonLogLevel.warn => daemon.LogLevel.WARN,
|
||||
DaemonLogLevel.info => daemon.LogLevel.INFO,
|
||||
DaemonLogLevel.debug => daemon.LogLevel.DEBUG,
|
||||
DaemonLogLevel.trace => daemon.LogLevel.TRACE,
|
||||
DaemonLogLevel.unknown => daemon.LogLevel.UNKNOWN,
|
||||
};
|
||||
}
|
||||
|
||||
void _checkUpdateMetadata(daemon.SystemEvent event) {
|
||||
final action = event.metadata['progress_window'];
|
||||
if (action != 'show') {
|
||||
return;
|
||||
}
|
||||
final version = event.metadata['version'] ?? 'unknown';
|
||||
if (!_updateRequests.isClosed) {
|
||||
_updateRequests.add(UpdateProgressEvent(version: version));
|
||||
}
|
||||
}
|
||||
|
||||
SystemNotification? _mapSystemEvent(daemon.SystemEvent event) {
|
||||
final severity = switch (event.severity) {
|
||||
daemon.SystemEvent_Severity.WARNING => NotificationSeverity.warning,
|
||||
daemon.SystemEvent_Severity.ERROR => NotificationSeverity.error,
|
||||
daemon.SystemEvent_Severity.CRITICAL => NotificationSeverity.critical,
|
||||
_ => NotificationSeverity.info,
|
||||
};
|
||||
final category = switch (event.category) {
|
||||
daemon.SystemEvent_Category.NETWORK => NotificationCategory.network,
|
||||
daemon.SystemEvent_Category.DNS => NotificationCategory.dns,
|
||||
daemon.SystemEvent_Category.AUTHENTICATION =>
|
||||
NotificationCategory.authentication,
|
||||
daemon.SystemEvent_Category.CONNECTIVITY =>
|
||||
NotificationCategory.connectivity,
|
||||
daemon.SystemEvent_Category.SYSTEM => NotificationCategory.system,
|
||||
_ => NotificationCategory.system,
|
||||
};
|
||||
return SystemNotification(
|
||||
severity: severity,
|
||||
category: category,
|
||||
message: event.message,
|
||||
userMessage: event.userMessage,
|
||||
id: event.metadata['id'],
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _runLoginFlow() async {
|
||||
final loginResponse = await _client.login(
|
||||
daemon.LoginRequest(
|
||||
isUnixDesktopClient: Platform.isLinux,
|
||||
profileName: _snapshot.activeProfile.name,
|
||||
username: _username(),
|
||||
hint: _snapshot.activeProfile.email,
|
||||
),
|
||||
options: _options(timeout: const Duration(seconds: 30)),
|
||||
);
|
||||
|
||||
if (!loginResponse.needsSSOLogin) {
|
||||
return;
|
||||
}
|
||||
|
||||
_snapshot = _snapshot.copyWith(
|
||||
status: ConnectionStatus.awaitingLogin,
|
||||
pendingLogin: PendingLogin(
|
||||
verificationUri: loginResponse.verificationURIComplete,
|
||||
userCode: loginResponse.userCode,
|
||||
),
|
||||
);
|
||||
_emit();
|
||||
|
||||
if (loginResponse.verificationURIComplete.isNotEmpty) {
|
||||
await openExternalUrl(loginResponse.verificationURIComplete);
|
||||
}
|
||||
|
||||
await _client.waitSSOLogin(
|
||||
daemon.WaitSSOLoginRequest(userCode: loginResponse.userCode),
|
||||
options: _options(timeout: _ssoLoginTimeout),
|
||||
);
|
||||
|
||||
_snapshot = _snapshot.copyWith(
|
||||
status: ConnectionStatus.connecting,
|
||||
clearPendingLogin: true,
|
||||
);
|
||||
_emit();
|
||||
}
|
||||
|
||||
Future<void> _runRpc(Future<void> Function() action) async {
|
||||
try {
|
||||
_snapshot = _snapshot.copyWith(clearError: true);
|
||||
_emit();
|
||||
await action();
|
||||
} catch (error) {
|
||||
_snapshot = _snapshot.copyWith(
|
||||
status: ConnectionStatus.error,
|
||||
errorMessage: _formatError(error),
|
||||
);
|
||||
_emit();
|
||||
} finally {
|
||||
await _refresh();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _refresh() async {
|
||||
try {
|
||||
final status = await _client.status(
|
||||
daemon.StatusRequest(),
|
||||
options: _options(),
|
||||
);
|
||||
|
||||
final activeProfile = await _loadActiveProfile();
|
||||
final profiles = await _loadProfiles(activeProfile);
|
||||
final networks = await _loadNetworks();
|
||||
final settings = await _loadSettings(activeProfile);
|
||||
|
||||
final mappedStatus = _mapStatus(status.status);
|
||||
final preserveAwaiting =
|
||||
_snapshot.status == ConnectionStatus.awaitingLogin &&
|
||||
mappedStatus != ConnectionStatus.connected;
|
||||
|
||||
_snapshot = ClientSnapshot(
|
||||
daemonAddr: daemonAddr,
|
||||
daemonVersion: status.daemonVersion.isEmpty
|
||||
? 'unknown'
|
||||
: status.daemonVersion,
|
||||
status: preserveAwaiting ? ConnectionStatus.awaitingLogin : mappedStatus,
|
||||
activeProfile: activeProfile,
|
||||
profiles: profiles,
|
||||
networks: networks,
|
||||
settings: settings,
|
||||
pendingLogin: preserveAwaiting ? _snapshot.pendingLogin : null,
|
||||
);
|
||||
} catch (error) {
|
||||
_snapshot = _snapshot.copyWith(
|
||||
status: ConnectionStatus.error,
|
||||
errorMessage: _formatError(error),
|
||||
);
|
||||
}
|
||||
_emit();
|
||||
}
|
||||
|
||||
Future<ProfileInfo> _loadActiveProfile() async {
|
||||
try {
|
||||
final active = await _client.getActiveProfile(
|
||||
daemon.GetActiveProfileRequest(),
|
||||
options: _options(),
|
||||
);
|
||||
if (active.profileName.isNotEmpty) {
|
||||
return ProfileInfo(
|
||||
name: active.profileName,
|
||||
email: _snapshot.activeProfile.email,
|
||||
active: true,
|
||||
);
|
||||
}
|
||||
} catch (_) {
|
||||
// Keep the status pane usable even when optional profile RPCs fail.
|
||||
}
|
||||
return _snapshot.activeProfile;
|
||||
}
|
||||
|
||||
Future<List<ProfileInfo>> _loadProfiles(ProfileInfo activeProfile) async {
|
||||
try {
|
||||
final response = await _client.listProfiles(
|
||||
daemon.ListProfilesRequest(username: _username()),
|
||||
options: _options(),
|
||||
);
|
||||
final profiles = response.profiles.map((profile) {
|
||||
return ProfileInfo(name: profile.name, active: profile.isActive);
|
||||
}).toList();
|
||||
if (profiles.isNotEmpty) {
|
||||
return profiles;
|
||||
}
|
||||
} catch (_) {
|
||||
// Profile listing is not required for core connection status.
|
||||
}
|
||||
return [activeProfile];
|
||||
}
|
||||
|
||||
Future<List<NetworkRoute>> _loadNetworks() async {
|
||||
try {
|
||||
final response = await _client.listNetworks(
|
||||
daemon.ListNetworksRequest(),
|
||||
options: _options(),
|
||||
);
|
||||
return _mapNetworks(response.routes);
|
||||
} catch (_) {
|
||||
return _snapshot.networks;
|
||||
}
|
||||
}
|
||||
|
||||
Future<ClientSettings> _loadSettings(ProfileInfo activeProfile) async {
|
||||
try {
|
||||
final config = await _client.getConfig(
|
||||
daemon.GetConfigRequest(
|
||||
profileName: activeProfile.name,
|
||||
username: _username(),
|
||||
),
|
||||
options: _options(),
|
||||
);
|
||||
return ClientSettings(
|
||||
managementUrl: config.managementUrl.isEmpty
|
||||
? 'https://api.netbird.io'
|
||||
: config.managementUrl,
|
||||
interfaceName: config.interfaceName.isEmpty
|
||||
? 'wt0'
|
||||
: config.interfaceName,
|
||||
wireguardPort: config.hasWireguardPort()
|
||||
? config.wireguardPort.toInt()
|
||||
: 51820,
|
||||
mtu: config.hasMtu() ? config.mtu.toInt() : 1280,
|
||||
autoConnect: !config.disableAutoConnect,
|
||||
allowSsh: config.serverSSHAllowed,
|
||||
quantumResistance: config.rosenpassEnabled,
|
||||
notifications: !config.disableNotifications,
|
||||
lazyConnection: config.lazyConnectionEnabled,
|
||||
blockInbound: config.blockInbound,
|
||||
);
|
||||
} catch (_) {
|
||||
return _snapshot.settings;
|
||||
}
|
||||
}
|
||||
|
||||
List<NetworkRoute> _mapNetworks(Iterable<daemon.Network> routes) {
|
||||
final rangeCounts = <String, int>{};
|
||||
for (final route in routes) {
|
||||
if (route.domains.isEmpty) {
|
||||
rangeCounts.update(
|
||||
route.range,
|
||||
(count) => count + 1,
|
||||
ifAbsent: () => 1,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return routes.map((route) {
|
||||
final resolvedIps = route.resolvedIPs.map((domain, ipList) {
|
||||
return MapEntry(domain, ipList.ips.toList());
|
||||
});
|
||||
|
||||
return NetworkRoute(
|
||||
id: route.iD,
|
||||
range: route.range,
|
||||
selected: route.selected,
|
||||
domains: route.domains.toList(),
|
||||
resolvedIps: resolvedIps,
|
||||
overlapping:
|
||||
route.domains.isEmpty && (rangeCounts[route.range] ?? 0) > 1,
|
||||
);
|
||||
}).toList()
|
||||
..sort((a, b) => a.id.toLowerCase().compareTo(b.id.toLowerCase()));
|
||||
}
|
||||
|
||||
CallOptions _options({Duration? timeout}) {
|
||||
return CallOptions(timeout: timeout ?? _callTimeout);
|
||||
}
|
||||
|
||||
void _setStatus(
|
||||
ConnectionStatus status, {
|
||||
bool clearError = false,
|
||||
bool clearPendingLogin = false,
|
||||
}) {
|
||||
_snapshot = _snapshot.copyWith(
|
||||
status: status,
|
||||
clearError: clearError,
|
||||
clearPendingLogin: clearPendingLogin,
|
||||
);
|
||||
_emit();
|
||||
}
|
||||
|
||||
void _emit() {
|
||||
if (!_snapshots.isClosed) {
|
||||
_snapshots.add(_snapshot);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class FakeDaemonClient implements DaemonClient {
|
||||
FakeDaemonClient({required this.daemonAddr}) {
|
||||
scheduleMicrotask(_emit);
|
||||
}
|
||||
|
||||
@override
|
||||
final String daemonAddr;
|
||||
|
||||
final _snapshots = StreamController<ClientSnapshot>.broadcast();
|
||||
|
||||
late ClientSnapshot _snapshot = ClientSnapshot.initial(daemonAddr).copyWith(
|
||||
daemonVersion: 'development',
|
||||
profiles: const [
|
||||
ProfileInfo(name: 'default', email: 'user@example.com', active: true),
|
||||
ProfileInfo(name: 'staging', active: false),
|
||||
],
|
||||
networks: const [
|
||||
NetworkRoute(id: 'office', range: '10.10.0.0/16', selected: true),
|
||||
NetworkRoute(id: 'prod', range: '10.20.0.0/16'),
|
||||
NetworkRoute(id: 'exit-us', range: '0.0.0.0/0'),
|
||||
],
|
||||
);
|
||||
|
||||
@override
|
||||
Stream<ClientSnapshot> watchSnapshot() {
|
||||
scheduleMicrotask(_emit);
|
||||
return _snapshots.stream;
|
||||
}
|
||||
|
||||
@override
|
||||
Stream<SystemNotification> watchEvents() =>
|
||||
const Stream<SystemNotification>.empty();
|
||||
|
||||
@override
|
||||
Stream<UpdateProgressEvent> watchUpdateRequests() =>
|
||||
const Stream<UpdateProgressEvent>.empty();
|
||||
|
||||
@override
|
||||
Future<void> connect() async {
|
||||
_snapshot = _snapshot.copyWith(status: ConnectionStatus.connecting);
|
||||
_emit();
|
||||
await Future<void>.delayed(const Duration(milliseconds: 450));
|
||||
_snapshot = _snapshot.copyWith(status: ConnectionStatus.connected);
|
||||
_emit();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> disconnect() async {
|
||||
_snapshot = _snapshot.copyWith(status: ConnectionStatus.disconnected);
|
||||
_emit();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> bringUp() async {
|
||||
_snapshot = _snapshot.copyWith(status: ConnectionStatus.connected);
|
||||
_emit();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> bringDown() async {
|
||||
_snapshot = _snapshot.copyWith(status: ConnectionStatus.disconnected);
|
||||
_emit();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<DebugBundleResult> debugBundle({
|
||||
required bool anonymize,
|
||||
required bool systemInfo,
|
||||
String? uploadUrl,
|
||||
}) async {
|
||||
await Future<void>.delayed(const Duration(milliseconds: 400));
|
||||
return DebugBundleResult(
|
||||
path: '/tmp/netbird-debug.tar.gz',
|
||||
uploadedKey: uploadUrl == null ? '' : 'fake-upload-key',
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<DaemonLogLevel> getLogLevel() async => DaemonLogLevel.info;
|
||||
|
||||
@override
|
||||
Future<void> setLogLevel(DaemonLogLevel level) async {}
|
||||
|
||||
@override
|
||||
Future<void> setSyncResponsePersistence(bool enabled) async {}
|
||||
|
||||
@override
|
||||
Future<void> startCpuProfile() async {}
|
||||
|
||||
@override
|
||||
Future<void> stopCpuProfile() async {}
|
||||
|
||||
@override
|
||||
Future<TriggerUpdateResult> triggerUpdate() async {
|
||||
return const TriggerUpdateResult(success: true);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<InstallerResult> getInstallerResult() async {
|
||||
await Future<void>.delayed(const Duration(seconds: 2));
|
||||
return const InstallerResult(success: true);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> updateSettings(ClientSettings updated) async {
|
||||
_snapshot = _snapshot.copyWith(settings: updated);
|
||||
_emit();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> setNetworkSelection(String routeId, bool selected) async {
|
||||
final next = _snapshot.networks.map((route) {
|
||||
if (route.id != routeId) {
|
||||
return route;
|
||||
}
|
||||
return NetworkRoute(
|
||||
id: route.id,
|
||||
range: route.range,
|
||||
domains: route.domains,
|
||||
resolvedIps: route.resolvedIps,
|
||||
overlapping: route.overlapping,
|
||||
selected: selected,
|
||||
);
|
||||
}).toList();
|
||||
_snapshot = _snapshot.copyWith(networks: next);
|
||||
_emit();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> setExitNode(String? routeId) async {
|
||||
final next = _snapshot.networks.map((route) {
|
||||
if (!route.isExitNode) {
|
||||
return route;
|
||||
}
|
||||
return NetworkRoute(
|
||||
id: route.id,
|
||||
range: route.range,
|
||||
domains: route.domains,
|
||||
resolvedIps: route.resolvedIps,
|
||||
overlapping: route.overlapping,
|
||||
selected: route.id == routeId,
|
||||
);
|
||||
}).toList();
|
||||
_snapshot = _snapshot.copyWith(networks: next);
|
||||
_emit();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> switchProfile(String name) async {
|
||||
final profiles = _snapshot.profiles.map((profile) {
|
||||
return ProfileInfo(
|
||||
name: profile.name,
|
||||
email: profile.email,
|
||||
active: profile.name == name,
|
||||
);
|
||||
}).toList();
|
||||
final active = profiles.firstWhere(
|
||||
(profile) => profile.active,
|
||||
orElse: () => _snapshot.activeProfile,
|
||||
);
|
||||
_snapshot = _snapshot.copyWith(profiles: profiles, activeProfile: active);
|
||||
_emit();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> addProfile(String name) async {
|
||||
final profiles = [
|
||||
..._snapshot.profiles,
|
||||
ProfileInfo(name: name, active: false),
|
||||
];
|
||||
_snapshot = _snapshot.copyWith(profiles: profiles);
|
||||
_emit();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> removeProfile(String name) async {
|
||||
final profiles = _snapshot.profiles
|
||||
.where((profile) => profile.name != name)
|
||||
.toList();
|
||||
_snapshot = _snapshot.copyWith(profiles: profiles);
|
||||
_emit();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> logoutActive() async {
|
||||
_snapshot = _snapshot.copyWith(status: ConnectionStatus.disconnected);
|
||||
_emit();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_snapshots.close();
|
||||
}
|
||||
|
||||
void _emit() {
|
||||
if (!_snapshots.isClosed) {
|
||||
_snapshots.add(_snapshot);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ClientChannel _createChannel(String daemonAddr) {
|
||||
final options = ChannelOptions(
|
||||
credentials: const ChannelCredentials.insecure(),
|
||||
userAgent: _userAgent,
|
||||
connectTimeout: const Duration(seconds: 3),
|
||||
);
|
||||
|
||||
if (daemonAddr.startsWith('unix://')) {
|
||||
final path = daemonAddr.substring('unix://'.length);
|
||||
return ClientChannel(
|
||||
InternetAddress(path, type: InternetAddressType.unix),
|
||||
port: 0,
|
||||
options: options,
|
||||
);
|
||||
}
|
||||
|
||||
final uri = daemonAddr.contains('://')
|
||||
? Uri.parse(daemonAddr)
|
||||
: Uri.parse('tcp://$daemonAddr');
|
||||
final host = uri.host.isEmpty ? '127.0.0.1' : uri.host;
|
||||
final port = uri.hasPort ? uri.port : 41731;
|
||||
return ClientChannel(host, port: port, options: options);
|
||||
}
|
||||
|
||||
ConnectionStatus _mapStatus(String status) {
|
||||
return switch (status) {
|
||||
'Connected' => ConnectionStatus.connected,
|
||||
'Connecting' => ConnectionStatus.connecting,
|
||||
'Idle' || 'SessionExpired' => ConnectionStatus.disconnected,
|
||||
_ => ConnectionStatus.error,
|
||||
};
|
||||
}
|
||||
|
||||
String _username() {
|
||||
if (Platform.isWindows) {
|
||||
final username = Platform.environment['USERNAME'] ?? '';
|
||||
final domain = Platform.environment['USERDOMAIN'] ?? '';
|
||||
if (domain.isNotEmpty && username.isNotEmpty) {
|
||||
return '$domain\\$username';
|
||||
}
|
||||
return username;
|
||||
}
|
||||
return Platform.environment['USER'] ?? Platform.environment['LOGNAME'] ?? '';
|
||||
}
|
||||
|
||||
String _formatError(Object error) {
|
||||
if (error is GrpcError) {
|
||||
return error.message ?? error.toString();
|
||||
}
|
||||
return error.toString();
|
||||
}
|
||||
460
client/flutter_ui/lib/src/debug_screen.dart
Normal file
@@ -0,0 +1,460 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import 'daemon_client.dart';
|
||||
import 'models.dart';
|
||||
import 'platform.dart';
|
||||
|
||||
const _defaultUploadUrl = 'https://upload.netbird.io/';
|
||||
|
||||
class DebugScreen extends StatefulWidget {
|
||||
const DebugScreen({required this.client, super.key});
|
||||
|
||||
final DaemonClient client;
|
||||
|
||||
@override
|
||||
State<DebugScreen> createState() => _DebugScreenState();
|
||||
}
|
||||
|
||||
class _DebugScreenState extends State<DebugScreen> {
|
||||
final _uploadUrlController =
|
||||
TextEditingController(text: _defaultUploadUrl);
|
||||
final _durationController = TextEditingController(text: '1');
|
||||
|
||||
bool _anonymize = false;
|
||||
bool _systemInfo = true;
|
||||
bool _upload = true;
|
||||
bool _runWithTrace = true;
|
||||
bool _busy = false;
|
||||
|
||||
String _status = '';
|
||||
double? _progress;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_uploadUrlController.dispose();
|
||||
_durationController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('Debug', style: Theme.of(context).textTheme.headlineSmall),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Create a debug bundle to help troubleshoot issues with NetBird.',
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Expanded(
|
||||
child: ListView(
|
||||
children: [
|
||||
CheckboxListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
value: _anonymize,
|
||||
onChanged: _busy
|
||||
? null
|
||||
: (value) => setState(() => _anonymize = value ?? false),
|
||||
title: const Text(
|
||||
'Anonymize sensitive information (public IPs, domains, ...)',
|
||||
),
|
||||
),
|
||||
CheckboxListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
value: _systemInfo,
|
||||
onChanged: _busy
|
||||
? null
|
||||
: (value) => setState(() => _systemInfo = value ?? false),
|
||||
title: const Text(
|
||||
'Include system information (routes, interfaces, ...)',
|
||||
),
|
||||
),
|
||||
CheckboxListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
value: _upload,
|
||||
onChanged: _busy
|
||||
? null
|
||||
: (value) => setState(() => _upload = value ?? false),
|
||||
title: const Text('Upload bundle automatically after creation'),
|
||||
),
|
||||
if (_upload)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 32, bottom: 8, top: 4),
|
||||
child: TextField(
|
||||
controller: _uploadUrlController,
|
||||
enabled: !_busy,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Debug upload URL',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
),
|
||||
const Divider(height: 32),
|
||||
CheckboxListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
value: _runWithTrace,
|
||||
onChanged: _busy
|
||||
? null
|
||||
: (value) =>
|
||||
setState(() => _runWithTrace = value ?? false),
|
||||
title: const Text(
|
||||
'Run with trace logs before creating bundle',
|
||||
),
|
||||
),
|
||||
if (_runWithTrace)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 32, top: 4),
|
||||
child: Row(
|
||||
children: [
|
||||
const Text('Run for'),
|
||||
const SizedBox(width: 12),
|
||||
SizedBox(
|
||||
width: 80,
|
||||
child: TextField(
|
||||
controller: _durationController,
|
||||
enabled: !_busy,
|
||||
keyboardType: TextInputType.number,
|
||||
inputFormatters: [
|
||||
FilteringTextInputFormatter.digitsOnly,
|
||||
],
|
||||
decoration: const InputDecoration(
|
||||
isDense: true,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(_durationLabel()),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (_runWithTrace)
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(left: 32, top: 8),
|
||||
child: Text(
|
||||
'Note: NetBird will be brought up and down during collection.',
|
||||
style: TextStyle(fontStyle: FontStyle.italic),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
if (_status.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: Text(_status),
|
||||
),
|
||||
if (_progress != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 16),
|
||||
child: LinearProgressIndicator(value: _progress),
|
||||
),
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: FilledButton.icon(
|
||||
onPressed: _busy ? null : _onCreate,
|
||||
icon: _busy
|
||||
? const SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.archive_outlined),
|
||||
label: const Text('Create Debug Bundle'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _durationLabel() {
|
||||
final value = int.tryParse(_durationController.text) ?? 0;
|
||||
return value == 1 ? 'minute' : 'minutes';
|
||||
}
|
||||
|
||||
Future<void> _onCreate() async {
|
||||
final uploadUrl = _upload ? _uploadUrlController.text.trim() : null;
|
||||
if (_upload && (uploadUrl == null || uploadUrl.isEmpty)) {
|
||||
setState(() => _status = 'Upload URL is required when upload is enabled');
|
||||
return;
|
||||
}
|
||||
|
||||
Duration? traceDuration;
|
||||
if (_runWithTrace) {
|
||||
final minutes = int.tryParse(_durationController.text);
|
||||
if (minutes == null || minutes < 1) {
|
||||
setState(() => _status = 'Duration must be a number ≥ 1');
|
||||
return;
|
||||
}
|
||||
traceDuration = Duration(minutes: minutes);
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_busy = true;
|
||||
_status = '';
|
||||
_progress = null;
|
||||
});
|
||||
|
||||
try {
|
||||
DebugBundleResult result;
|
||||
if (traceDuration != null) {
|
||||
result = await _runWithTraceLogs(
|
||||
duration: traceDuration,
|
||||
uploadUrl: uploadUrl,
|
||||
);
|
||||
} else {
|
||||
setState(() => _status = 'Creating debug bundle...');
|
||||
result = await widget.client.debugBundle(
|
||||
anonymize: _anonymize,
|
||||
systemInfo: _systemInfo,
|
||||
uploadUrl: uploadUrl,
|
||||
);
|
||||
}
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
setState(() => _status = 'Bundle created successfully');
|
||||
await _showResultDialog(result);
|
||||
} catch (error) {
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_status = 'Error: $error';
|
||||
_progress = null;
|
||||
});
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() => _busy = false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<DebugBundleResult> _runWithTraceLogs({
|
||||
required Duration duration,
|
||||
required String? uploadUrl,
|
||||
}) async {
|
||||
final initialLevel = await widget.client.getLogLevel();
|
||||
final wasTrace = initialLevel == DaemonLogLevel.trace;
|
||||
|
||||
var levelChanged = false;
|
||||
var persistenceEnabled = false;
|
||||
var cpuProfileStarted = false;
|
||||
|
||||
try {
|
||||
if (!wasTrace) {
|
||||
await widget.client.setLogLevel(DaemonLogLevel.trace);
|
||||
levelChanged = true;
|
||||
}
|
||||
|
||||
try {
|
||||
await widget.client.bringDown();
|
||||
} catch (_) {
|
||||
// Already down is fine; daemon returns OK either way.
|
||||
}
|
||||
await Future<void>.delayed(const Duration(seconds: 1));
|
||||
|
||||
try {
|
||||
await widget.client.setSyncResponsePersistence(true);
|
||||
persistenceEnabled = true;
|
||||
} catch (_) {
|
||||
// Persistence is best-effort — the bundle still works without it.
|
||||
}
|
||||
|
||||
await widget.client.bringUp();
|
||||
await Future<void>.delayed(const Duration(seconds: 3));
|
||||
|
||||
try {
|
||||
await widget.client.startCpuProfile();
|
||||
cpuProfileStarted = true;
|
||||
} catch (_) {
|
||||
// CPU profiling is optional.
|
||||
}
|
||||
|
||||
await _trackProgress(duration);
|
||||
|
||||
if (cpuProfileStarted) {
|
||||
try {
|
||||
await widget.client.stopCpuProfile();
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
if (!mounted) {
|
||||
return const DebugBundleResult(path: '');
|
||||
}
|
||||
setState(() {
|
||||
_status = 'Creating debug bundle with collected logs...';
|
||||
_progress = null;
|
||||
});
|
||||
|
||||
return await widget.client.debugBundle(
|
||||
anonymize: _anonymize,
|
||||
systemInfo: _systemInfo,
|
||||
uploadUrl: uploadUrl,
|
||||
);
|
||||
} finally {
|
||||
if (levelChanged) {
|
||||
try {
|
||||
await widget.client.setLogLevel(initialLevel);
|
||||
} catch (_) {}
|
||||
}
|
||||
if (persistenceEnabled) {
|
||||
try {
|
||||
await widget.client.setSyncResponsePersistence(false);
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _trackProgress(Duration total) async {
|
||||
final start = DateTime.now();
|
||||
final end = start.add(total);
|
||||
setState(() {
|
||||
_progress = 0;
|
||||
_status = 'Running with trace logs... ${_formatRemaining(total)} remaining';
|
||||
});
|
||||
|
||||
while (DateTime.now().isBefore(end)) {
|
||||
await Future<void>.delayed(const Duration(milliseconds: 500));
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
final elapsed = DateTime.now().difference(start);
|
||||
final fraction = (elapsed.inMilliseconds / total.inMilliseconds).clamp(
|
||||
0.0,
|
||||
1.0,
|
||||
);
|
||||
final remaining = end.difference(DateTime.now());
|
||||
setState(() {
|
||||
_progress = fraction;
|
||||
_status =
|
||||
'Running with trace logs... ${_formatRemaining(remaining < Duration.zero ? Duration.zero : remaining)} remaining';
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
String _formatRemaining(Duration d) {
|
||||
final hours = d.inHours.toString().padLeft(2, '0');
|
||||
final minutes = (d.inMinutes % 60).toString().padLeft(2, '0');
|
||||
final seconds = (d.inSeconds % 60).toString().padLeft(2, '0');
|
||||
return '$hours:$minutes:$seconds';
|
||||
}
|
||||
|
||||
Future<void> _showResultDialog(DebugBundleResult result) async {
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
await showDialog<void>(
|
||||
context: context,
|
||||
builder: (context) => _DebugResultDialog(result: result),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DebugResultDialog extends StatelessWidget {
|
||||
const _DebugResultDialog({required this.result});
|
||||
|
||||
final DebugBundleResult result;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final folder = _parentFolder(result.path);
|
||||
|
||||
String title;
|
||||
Widget body;
|
||||
if (result.uploadFailed) {
|
||||
title = 'Upload Failed';
|
||||
body = Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text('Bundle upload failed:\n${result.uploadFailureReason}'),
|
||||
const SizedBox(height: 12),
|
||||
SelectableText('Local copy: ${result.path}'),
|
||||
],
|
||||
);
|
||||
} else if (result.uploaded) {
|
||||
title = 'Upload Successful';
|
||||
body = Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Text('Bundle uploaded successfully.'),
|
||||
const SizedBox(height: 12),
|
||||
const Text('Upload key:'),
|
||||
SelectableText(result.uploadedKey),
|
||||
const SizedBox(height: 12),
|
||||
SelectableText('Local copy: ${result.path}'),
|
||||
],
|
||||
);
|
||||
} else {
|
||||
title = 'Debug Bundle Created';
|
||||
body = Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text('Bundle created locally at:\n${result.path}'),
|
||||
const SizedBox(height: 8),
|
||||
const Text(
|
||||
'Administrator privileges may be required to access the file.',
|
||||
style: TextStyle(fontStyle: FontStyle.italic),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
return AlertDialog(
|
||||
title: Text(title),
|
||||
content: SingleChildScrollView(child: body),
|
||||
actions: [
|
||||
if (result.uploaded)
|
||||
TextButton.icon(
|
||||
onPressed: () async {
|
||||
await Clipboard.setData(
|
||||
ClipboardData(text: result.uploadedKey),
|
||||
);
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Upload key copied')),
|
||||
);
|
||||
}
|
||||
},
|
||||
icon: const Icon(Icons.copy),
|
||||
label: const Text('Copy key'),
|
||||
),
|
||||
TextButton.icon(
|
||||
onPressed: result.path.isEmpty
|
||||
? null
|
||||
: () => openExternalUrl(result.path),
|
||||
icon: const Icon(Icons.description_outlined),
|
||||
label: const Text('Open file'),
|
||||
),
|
||||
TextButton.icon(
|
||||
onPressed: folder.isEmpty ? null : () => openExternalUrl(folder),
|
||||
icon: const Icon(Icons.folder_open),
|
||||
label: const Text('Open folder'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('Close'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
String _parentFolder(String path) {
|
||||
if (path.isEmpty) {
|
||||
return '';
|
||||
}
|
||||
final lastSlash = path.lastIndexOf(RegExp(r'[/\\]'));
|
||||
return lastSlash <= 0 ? '' : path.substring(0, lastSlash);
|
||||
}
|
||||
}
|
||||
434
client/flutter_ui/lib/src/desktop_integration.dart
Normal file
@@ -0,0 +1,434 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:local_notifier/local_notifier.dart';
|
||||
import 'package:tray_manager/tray_manager.dart';
|
||||
import 'package:window_manager/window_manager.dart';
|
||||
|
||||
import 'daemon_client.dart';
|
||||
import 'models.dart';
|
||||
import 'platform.dart';
|
||||
|
||||
const uiVersion = '0.1.0';
|
||||
const _githubUrl = 'https://github.com/netbirdio/netbird';
|
||||
const _downloadUrl = 'https://netbird.io/download/';
|
||||
|
||||
class TabIndex {
|
||||
static const status = 0;
|
||||
static const networks = 1;
|
||||
static const profiles = 2;
|
||||
static const settings = 3;
|
||||
static const debug = 4;
|
||||
}
|
||||
|
||||
/// Owns native desktop integration: window lifecycle (hide on close), system
|
||||
/// tray icon and menu, and OS-level notifications driven by daemon events.
|
||||
class DesktopIntegration with TrayListener, WindowListener {
|
||||
DesktopIntegration({required this.client});
|
||||
|
||||
final DaemonClient client;
|
||||
final _tabRequests = StreamController<int>.broadcast();
|
||||
|
||||
StreamSubscription<ClientSnapshot>? _snapshotSub;
|
||||
StreamSubscription<SystemNotification>? _eventSub;
|
||||
ClientSnapshot? _lastSnapshot;
|
||||
String? _lastMenuKey;
|
||||
bool _disposed = false;
|
||||
bool _settingsBusy = false;
|
||||
|
||||
Stream<int> get tabRequests => _tabRequests.stream;
|
||||
|
||||
static const _trayMenuConnect = 'connect';
|
||||
static const _trayMenuDisconnect = 'disconnect';
|
||||
static const _trayMenuShow = 'show';
|
||||
static const _trayMenuQuit = 'quit';
|
||||
static const _trayMenuAllowSSH = 'settings.allow_ssh';
|
||||
static const _trayMenuAutoConnect = 'settings.auto_connect';
|
||||
static const _trayMenuQuantum = 'settings.quantum';
|
||||
static const _trayMenuLazy = 'settings.lazy';
|
||||
static const _trayMenuBlockInbound = 'settings.block_inbound';
|
||||
static const _trayMenuNotifications = 'settings.notifications';
|
||||
static const _trayMenuAdvancedSettings = 'open.settings';
|
||||
static const _trayMenuDebugBundle = 'open.debug';
|
||||
static const _trayMenuNetworks = 'open.networks';
|
||||
static const _trayMenuManageProfiles = 'open.profiles';
|
||||
static const _trayMenuLogout = 'profile.logout';
|
||||
static const _trayMenuGithub = 'about.github';
|
||||
static const _trayMenuDownload = 'about.download';
|
||||
static const _profileSwitchPrefix = 'profile.switch:';
|
||||
|
||||
Future<void> initialize() async {
|
||||
await localNotifier.setup(appName: 'NetBird');
|
||||
await windowManager.setPreventClose(true);
|
||||
windowManager.addListener(this);
|
||||
trayManager.addListener(this);
|
||||
|
||||
await _applyTrayIcon(ConnectionStatus.disconnected);
|
||||
await trayManager.setToolTip('NetBird');
|
||||
await _refreshTrayMenu(null);
|
||||
|
||||
_snapshotSub = client.watchSnapshot().listen(_onSnapshot);
|
||||
_eventSub = client.watchEvents().listen(_onEvent);
|
||||
}
|
||||
|
||||
Future<void> dispose() async {
|
||||
if (_disposed) {
|
||||
return;
|
||||
}
|
||||
_disposed = true;
|
||||
await _snapshotSub?.cancel();
|
||||
await _eventSub?.cancel();
|
||||
await _tabRequests.close();
|
||||
windowManager.removeListener(this);
|
||||
trayManager.removeListener(this);
|
||||
await trayManager.destroy();
|
||||
}
|
||||
|
||||
@override
|
||||
void onWindowClose() {
|
||||
unawaited(_handleWindowClose());
|
||||
}
|
||||
|
||||
Future<void> _handleWindowClose() async {
|
||||
final prevent = await windowManager.isPreventClose();
|
||||
if (prevent) {
|
||||
await windowManager.hide();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void onTrayIconMouseDown() {
|
||||
if (Platform.isMacOS) {
|
||||
unawaited(trayManager.popUpContextMenu());
|
||||
} else {
|
||||
unawaited(_showWindow());
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void onTrayIconRightMouseDown() {
|
||||
unawaited(trayManager.popUpContextMenu());
|
||||
}
|
||||
|
||||
@override
|
||||
void onTrayMenuItemClick(MenuItem menuItem) {
|
||||
final key = menuItem.key;
|
||||
if (key == null) {
|
||||
return;
|
||||
}
|
||||
if (key.startsWith(_profileSwitchPrefix)) {
|
||||
final name = key.substring(_profileSwitchPrefix.length);
|
||||
unawaited(_switchProfile(name));
|
||||
return;
|
||||
}
|
||||
switch (key) {
|
||||
case _trayMenuConnect:
|
||||
unawaited(client.connect());
|
||||
case _trayMenuDisconnect:
|
||||
unawaited(client.disconnect());
|
||||
case _trayMenuShow:
|
||||
unawaited(_showWindow());
|
||||
case _trayMenuQuit:
|
||||
unawaited(_quit());
|
||||
case _trayMenuAllowSSH:
|
||||
unawaited(_toggleSetting((s) => s.copyWith(allowSsh: !s.allowSsh)));
|
||||
case _trayMenuAutoConnect:
|
||||
unawaited(
|
||||
_toggleSetting((s) => s.copyWith(autoConnect: !s.autoConnect)),
|
||||
);
|
||||
case _trayMenuQuantum:
|
||||
unawaited(
|
||||
_toggleSetting(
|
||||
(s) => s.copyWith(quantumResistance: !s.quantumResistance),
|
||||
),
|
||||
);
|
||||
case _trayMenuLazy:
|
||||
unawaited(
|
||||
_toggleSetting(
|
||||
(s) => s.copyWith(lazyConnection: !s.lazyConnection),
|
||||
),
|
||||
);
|
||||
case _trayMenuBlockInbound:
|
||||
unawaited(
|
||||
_toggleSetting(
|
||||
(s) => s.copyWith(blockInbound: !s.blockInbound),
|
||||
),
|
||||
);
|
||||
case _trayMenuNotifications:
|
||||
unawaited(
|
||||
_toggleSetting(
|
||||
(s) => s.copyWith(notifications: !s.notifications),
|
||||
),
|
||||
);
|
||||
case _trayMenuAdvancedSettings:
|
||||
unawaited(_openTab(TabIndex.settings));
|
||||
case _trayMenuDebugBundle:
|
||||
unawaited(_openTab(TabIndex.debug));
|
||||
case _trayMenuNetworks:
|
||||
unawaited(_openTab(TabIndex.networks));
|
||||
case _trayMenuManageProfiles:
|
||||
unawaited(_openTab(TabIndex.profiles));
|
||||
case _trayMenuLogout:
|
||||
unawaited(client.logoutActive());
|
||||
case _trayMenuGithub:
|
||||
unawaited(openExternalUrl(_githubUrl));
|
||||
case _trayMenuDownload:
|
||||
unawaited(openExternalUrl(_downloadUrl));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _openTab(int index) async {
|
||||
if (!_tabRequests.isClosed) {
|
||||
_tabRequests.add(index);
|
||||
}
|
||||
await _showWindow();
|
||||
}
|
||||
|
||||
Future<void> _toggleSetting(
|
||||
ClientSettings Function(ClientSettings) mutate,
|
||||
) async {
|
||||
if (_settingsBusy) {
|
||||
return;
|
||||
}
|
||||
final snapshot = _lastSnapshot;
|
||||
if (snapshot == null) {
|
||||
return;
|
||||
}
|
||||
_settingsBusy = true;
|
||||
try {
|
||||
await client.updateSettings(mutate(snapshot.settings));
|
||||
} finally {
|
||||
_settingsBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _switchProfile(String name) async {
|
||||
final snapshot = _lastSnapshot;
|
||||
if (snapshot == null || snapshot.activeProfile.name == name) {
|
||||
return;
|
||||
}
|
||||
await client.switchProfile(name);
|
||||
}
|
||||
|
||||
Future<void> _showWindow() async {
|
||||
await windowManager.show();
|
||||
await windowManager.focus();
|
||||
}
|
||||
|
||||
Future<void> _quit() async {
|
||||
await dispose();
|
||||
await windowManager.setPreventClose(false);
|
||||
await windowManager.destroy();
|
||||
}
|
||||
|
||||
void _onSnapshot(ClientSnapshot snapshot) {
|
||||
final previous = _lastSnapshot;
|
||||
_lastSnapshot = snapshot;
|
||||
if (previous == null || previous.status != snapshot.status) {
|
||||
unawaited(_applyTrayIcon(snapshot.status));
|
||||
unawaited(trayManager.setToolTip('NetBird — ${snapshot.status.label}'));
|
||||
}
|
||||
unawaited(_refreshTrayMenu(snapshot));
|
||||
}
|
||||
|
||||
void _onEvent(SystemNotification event) {
|
||||
if (event.userMessage.isEmpty) {
|
||||
return;
|
||||
}
|
||||
final notificationsEnabled =
|
||||
_lastSnapshot?.settings.notifications ?? true;
|
||||
final critical = event.severity == NotificationSeverity.critical;
|
||||
if (!notificationsEnabled && !critical) {
|
||||
return;
|
||||
}
|
||||
|
||||
final title = '${_severityPrefix(event.severity)} [${event.category.label}]';
|
||||
final body = event.id == null
|
||||
? event.userMessage
|
||||
: '${event.userMessage} (id: ${event.id})';
|
||||
|
||||
unawaited(
|
||||
LocalNotification(title: title, body: body).show(),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _applyTrayIcon(ConnectionStatus status) async {
|
||||
final basename = switch (status) {
|
||||
ConnectionStatus.connected => 'connected',
|
||||
ConnectionStatus.connecting ||
|
||||
ConnectionStatus.awaitingLogin => 'connecting',
|
||||
ConnectionStatus.error => 'error',
|
||||
ConnectionStatus.disconnected => 'disconnected',
|
||||
};
|
||||
final asset = Platform.isMacOS
|
||||
? 'assets/tray/$basename-macos.png'
|
||||
: 'assets/tray/$basename.png';
|
||||
await trayManager.setIcon(asset, isTemplate: Platform.isMacOS);
|
||||
}
|
||||
|
||||
Future<void> _refreshTrayMenu(ClientSnapshot? snapshot) async {
|
||||
final key = _menuKey(snapshot);
|
||||
if (key == _lastMenuKey) {
|
||||
return;
|
||||
}
|
||||
_lastMenuKey = key;
|
||||
|
||||
final connected = snapshot?.status == ConnectionStatus.connected;
|
||||
final connecting = snapshot?.status == ConnectionStatus.connecting ||
|
||||
snapshot?.status == ConnectionStatus.awaitingLogin;
|
||||
|
||||
final statusLabel =
|
||||
snapshot?.status.label ?? ConnectionStatus.disconnected.label;
|
||||
final settings = snapshot?.settings ?? const ClientSettings();
|
||||
final activeName = snapshot?.activeProfile.name ?? 'unknown';
|
||||
final email = snapshot?.activeProfile.email;
|
||||
final daemonVersion = snapshot?.daemonVersion ?? 'unknown';
|
||||
|
||||
final profileItems = <MenuItem>[];
|
||||
final profiles = snapshot?.profiles ?? const <ProfileInfo>[];
|
||||
for (final profile in profiles) {
|
||||
profileItems.add(
|
||||
MenuItem.checkbox(
|
||||
key: '$_profileSwitchPrefix${profile.name}',
|
||||
label: profile.name,
|
||||
checked: profile.active,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (profileItems.isNotEmpty) {
|
||||
profileItems.add(MenuItem.separator());
|
||||
}
|
||||
profileItems
|
||||
..add(MenuItem(key: _trayMenuManageProfiles, label: 'Manage Profiles'))
|
||||
..add(
|
||||
MenuItem(
|
||||
key: _trayMenuLogout,
|
||||
label: 'Deregister',
|
||||
disabled: !connected,
|
||||
),
|
||||
);
|
||||
|
||||
await trayManager.setContextMenu(
|
||||
Menu(
|
||||
items: [
|
||||
MenuItem(label: statusLabel, disabled: true),
|
||||
MenuItem.submenu(
|
||||
label: 'Profile: $activeName',
|
||||
submenu: Menu(items: profileItems),
|
||||
),
|
||||
if (email != null && email.isNotEmpty)
|
||||
MenuItem(label: '($email)', disabled: true),
|
||||
MenuItem.separator(),
|
||||
MenuItem(
|
||||
key: _trayMenuConnect,
|
||||
label: 'Connect',
|
||||
disabled: connected || connecting,
|
||||
),
|
||||
MenuItem(
|
||||
key: _trayMenuDisconnect,
|
||||
label: 'Disconnect',
|
||||
disabled: !connected,
|
||||
),
|
||||
MenuItem.separator(),
|
||||
MenuItem.submenu(
|
||||
label: 'Settings',
|
||||
submenu: Menu(
|
||||
items: [
|
||||
MenuItem.checkbox(
|
||||
key: _trayMenuAllowSSH,
|
||||
label: 'Allow SSH',
|
||||
checked: settings.allowSsh,
|
||||
),
|
||||
MenuItem.checkbox(
|
||||
key: _trayMenuAutoConnect,
|
||||
label: 'Connect on Startup',
|
||||
checked: settings.autoConnect,
|
||||
),
|
||||
MenuItem.checkbox(
|
||||
key: _trayMenuQuantum,
|
||||
label: 'Enable Quantum-Resistance',
|
||||
checked: settings.quantumResistance,
|
||||
),
|
||||
MenuItem.checkbox(
|
||||
key: _trayMenuLazy,
|
||||
label: 'Enable Lazy Connections',
|
||||
checked: settings.lazyConnection,
|
||||
),
|
||||
MenuItem.checkbox(
|
||||
key: _trayMenuBlockInbound,
|
||||
label: 'Block Inbound Connections',
|
||||
checked: settings.blockInbound,
|
||||
),
|
||||
MenuItem.checkbox(
|
||||
key: _trayMenuNotifications,
|
||||
label: 'Notifications',
|
||||
checked: settings.notifications,
|
||||
),
|
||||
MenuItem.separator(),
|
||||
MenuItem(
|
||||
key: _trayMenuAdvancedSettings,
|
||||
label: 'Advanced Settings',
|
||||
),
|
||||
MenuItem(
|
||||
key: _trayMenuDebugBundle,
|
||||
label: 'Create Debug Bundle',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
MenuItem(key: _trayMenuNetworks, label: 'Networks'),
|
||||
MenuItem.separator(),
|
||||
MenuItem.submenu(
|
||||
label: 'About',
|
||||
submenu: Menu(
|
||||
items: [
|
||||
MenuItem(key: _trayMenuGithub, label: 'GitHub'),
|
||||
MenuItem(label: 'GUI: $uiVersion', disabled: true),
|
||||
MenuItem(label: 'Daemon: $daemonVersion', disabled: true),
|
||||
MenuItem(
|
||||
key: _trayMenuDownload,
|
||||
label: 'Download latest version',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
MenuItem.separator(),
|
||||
MenuItem(key: _trayMenuShow, label: 'Show window'),
|
||||
MenuItem(key: _trayMenuQuit, label: 'Quit'),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _menuKey(ClientSnapshot? snapshot) {
|
||||
if (snapshot == null) {
|
||||
return 'null';
|
||||
}
|
||||
final s = snapshot.settings;
|
||||
final profiles = snapshot.profiles
|
||||
.map((p) => '${p.name}:${p.active}:${p.email ?? ''}')
|
||||
.join(',');
|
||||
return [
|
||||
snapshot.status.name,
|
||||
snapshot.activeProfile.name,
|
||||
snapshot.activeProfile.email ?? '',
|
||||
snapshot.daemonVersion,
|
||||
profiles,
|
||||
s.allowSsh,
|
||||
s.autoConnect,
|
||||
s.quantumResistance,
|
||||
s.lazyConnection,
|
||||
s.blockInbound,
|
||||
s.notifications,
|
||||
].join('|');
|
||||
}
|
||||
|
||||
String _severityPrefix(NotificationSeverity severity) {
|
||||
return switch (severity) {
|
||||
NotificationSeverity.critical => 'Critical',
|
||||
NotificationSeverity.error => 'Error',
|
||||
NotificationSeverity.warning => 'Warning',
|
||||
NotificationSeverity.info => 'Info',
|
||||
};
|
||||
}
|
||||
}
|
||||
7393
client/flutter_ui/lib/src/generated/daemon.pb.dart
Normal file
153
client/flutter_ui/lib/src/generated/daemon.pbenum.dart
Normal file
@@ -0,0 +1,153 @@
|
||||
// This is a generated file - do not edit.
|
||||
//
|
||||
// Generated from daemon.proto.
|
||||
|
||||
// @dart = 3.3
|
||||
|
||||
// ignore_for_file: annotate_overrides, camel_case_types, comment_references
|
||||
// ignore_for_file: constant_identifier_names
|
||||
// ignore_for_file: curly_braces_in_flow_control_structures
|
||||
// ignore_for_file: deprecated_member_use_from_same_package, library_prefixes
|
||||
// ignore_for_file: non_constant_identifier_names, prefer_relative_imports
|
||||
|
||||
import 'dart:core' as $core;
|
||||
|
||||
import 'package:protobuf/protobuf.dart' as $pb;
|
||||
|
||||
class LogLevel extends $pb.ProtobufEnum {
|
||||
static const LogLevel UNKNOWN =
|
||||
LogLevel._(0, _omitEnumNames ? '' : 'UNKNOWN');
|
||||
static const LogLevel PANIC = LogLevel._(1, _omitEnumNames ? '' : 'PANIC');
|
||||
static const LogLevel FATAL = LogLevel._(2, _omitEnumNames ? '' : 'FATAL');
|
||||
static const LogLevel ERROR = LogLevel._(3, _omitEnumNames ? '' : 'ERROR');
|
||||
static const LogLevel WARN = LogLevel._(4, _omitEnumNames ? '' : 'WARN');
|
||||
static const LogLevel INFO = LogLevel._(5, _omitEnumNames ? '' : 'INFO');
|
||||
static const LogLevel DEBUG = LogLevel._(6, _omitEnumNames ? '' : 'DEBUG');
|
||||
static const LogLevel TRACE = LogLevel._(7, _omitEnumNames ? '' : 'TRACE');
|
||||
|
||||
static const $core.List<LogLevel> values = <LogLevel>[
|
||||
UNKNOWN,
|
||||
PANIC,
|
||||
FATAL,
|
||||
ERROR,
|
||||
WARN,
|
||||
INFO,
|
||||
DEBUG,
|
||||
TRACE,
|
||||
];
|
||||
|
||||
static final $core.List<LogLevel?> _byValue =
|
||||
$pb.ProtobufEnum.$_initByValueList(values, 7);
|
||||
static LogLevel? valueOf($core.int value) =>
|
||||
value < 0 || value >= _byValue.length ? null : _byValue[value];
|
||||
|
||||
const LogLevel._(super.value, super.name);
|
||||
}
|
||||
|
||||
class ExposeProtocol extends $pb.ProtobufEnum {
|
||||
static const ExposeProtocol EXPOSE_HTTP =
|
||||
ExposeProtocol._(0, _omitEnumNames ? '' : 'EXPOSE_HTTP');
|
||||
static const ExposeProtocol EXPOSE_HTTPS =
|
||||
ExposeProtocol._(1, _omitEnumNames ? '' : 'EXPOSE_HTTPS');
|
||||
static const ExposeProtocol EXPOSE_TCP =
|
||||
ExposeProtocol._(2, _omitEnumNames ? '' : 'EXPOSE_TCP');
|
||||
static const ExposeProtocol EXPOSE_UDP =
|
||||
ExposeProtocol._(3, _omitEnumNames ? '' : 'EXPOSE_UDP');
|
||||
static const ExposeProtocol EXPOSE_TLS =
|
||||
ExposeProtocol._(4, _omitEnumNames ? '' : 'EXPOSE_TLS');
|
||||
|
||||
static const $core.List<ExposeProtocol> values = <ExposeProtocol>[
|
||||
EXPOSE_HTTP,
|
||||
EXPOSE_HTTPS,
|
||||
EXPOSE_TCP,
|
||||
EXPOSE_UDP,
|
||||
EXPOSE_TLS,
|
||||
];
|
||||
|
||||
static final $core.List<ExposeProtocol?> _byValue =
|
||||
$pb.ProtobufEnum.$_initByValueList(values, 4);
|
||||
static ExposeProtocol? valueOf($core.int value) =>
|
||||
value < 0 || value >= _byValue.length ? null : _byValue[value];
|
||||
|
||||
const ExposeProtocol._(super.value, super.name);
|
||||
}
|
||||
|
||||
/// avoid collision with loglevel enum
|
||||
class OSLifecycleRequest_CycleType extends $pb.ProtobufEnum {
|
||||
static const OSLifecycleRequest_CycleType UNKNOWN =
|
||||
OSLifecycleRequest_CycleType._(0, _omitEnumNames ? '' : 'UNKNOWN');
|
||||
static const OSLifecycleRequest_CycleType SLEEP =
|
||||
OSLifecycleRequest_CycleType._(1, _omitEnumNames ? '' : 'SLEEP');
|
||||
static const OSLifecycleRequest_CycleType WAKEUP =
|
||||
OSLifecycleRequest_CycleType._(2, _omitEnumNames ? '' : 'WAKEUP');
|
||||
|
||||
static const $core.List<OSLifecycleRequest_CycleType> values =
|
||||
<OSLifecycleRequest_CycleType>[
|
||||
UNKNOWN,
|
||||
SLEEP,
|
||||
WAKEUP,
|
||||
];
|
||||
|
||||
static final $core.List<OSLifecycleRequest_CycleType?> _byValue =
|
||||
$pb.ProtobufEnum.$_initByValueList(values, 2);
|
||||
static OSLifecycleRequest_CycleType? valueOf($core.int value) =>
|
||||
value < 0 || value >= _byValue.length ? null : _byValue[value];
|
||||
|
||||
const OSLifecycleRequest_CycleType._(super.value, super.name);
|
||||
}
|
||||
|
||||
class SystemEvent_Severity extends $pb.ProtobufEnum {
|
||||
static const SystemEvent_Severity INFO =
|
||||
SystemEvent_Severity._(0, _omitEnumNames ? '' : 'INFO');
|
||||
static const SystemEvent_Severity WARNING =
|
||||
SystemEvent_Severity._(1, _omitEnumNames ? '' : 'WARNING');
|
||||
static const SystemEvent_Severity ERROR =
|
||||
SystemEvent_Severity._(2, _omitEnumNames ? '' : 'ERROR');
|
||||
static const SystemEvent_Severity CRITICAL =
|
||||
SystemEvent_Severity._(3, _omitEnumNames ? '' : 'CRITICAL');
|
||||
|
||||
static const $core.List<SystemEvent_Severity> values = <SystemEvent_Severity>[
|
||||
INFO,
|
||||
WARNING,
|
||||
ERROR,
|
||||
CRITICAL,
|
||||
];
|
||||
|
||||
static final $core.List<SystemEvent_Severity?> _byValue =
|
||||
$pb.ProtobufEnum.$_initByValueList(values, 3);
|
||||
static SystemEvent_Severity? valueOf($core.int value) =>
|
||||
value < 0 || value >= _byValue.length ? null : _byValue[value];
|
||||
|
||||
const SystemEvent_Severity._(super.value, super.name);
|
||||
}
|
||||
|
||||
class SystemEvent_Category extends $pb.ProtobufEnum {
|
||||
static const SystemEvent_Category NETWORK =
|
||||
SystemEvent_Category._(0, _omitEnumNames ? '' : 'NETWORK');
|
||||
static const SystemEvent_Category DNS =
|
||||
SystemEvent_Category._(1, _omitEnumNames ? '' : 'DNS');
|
||||
static const SystemEvent_Category AUTHENTICATION =
|
||||
SystemEvent_Category._(2, _omitEnumNames ? '' : 'AUTHENTICATION');
|
||||
static const SystemEvent_Category CONNECTIVITY =
|
||||
SystemEvent_Category._(3, _omitEnumNames ? '' : 'CONNECTIVITY');
|
||||
static const SystemEvent_Category SYSTEM =
|
||||
SystemEvent_Category._(4, _omitEnumNames ? '' : 'SYSTEM');
|
||||
|
||||
static const $core.List<SystemEvent_Category> values = <SystemEvent_Category>[
|
||||
NETWORK,
|
||||
DNS,
|
||||
AUTHENTICATION,
|
||||
CONNECTIVITY,
|
||||
SYSTEM,
|
||||
];
|
||||
|
||||
static final $core.List<SystemEvent_Category?> _byValue =
|
||||
$pb.ProtobufEnum.$_initByValueList(values, 4);
|
||||
static SystemEvent_Category? valueOf($core.int value) =>
|
||||
value < 0 || value >= _byValue.length ? null : _byValue[value];
|
||||
|
||||
const SystemEvent_Category._(super.value, super.name);
|
||||
}
|
||||
|
||||
const $core.bool _omitEnumNames =
|
||||
$core.bool.fromEnvironment('protobuf.omit_enum_names');
|
||||
1141
client/flutter_ui/lib/src/generated/daemon.pbgrpc.dart
Normal file
2589
client/flutter_ui/lib/src/generated/daemon.pbjson.dart
Normal file
257
client/flutter_ui/lib/src/models.dart
Normal file
@@ -0,0 +1,257 @@
|
||||
enum ConnectionStatus {
|
||||
disconnected,
|
||||
connecting,
|
||||
awaitingLogin,
|
||||
connected,
|
||||
error;
|
||||
|
||||
String get label {
|
||||
return switch (this) {
|
||||
ConnectionStatus.disconnected => 'Disconnected',
|
||||
ConnectionStatus.connecting => 'Connecting',
|
||||
ConnectionStatus.awaitingLogin => 'Awaiting login',
|
||||
ConnectionStatus.connected => 'Connected',
|
||||
ConnectionStatus.error => 'Error',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
enum NetworkFilter {
|
||||
all,
|
||||
overlapping,
|
||||
exitNode;
|
||||
|
||||
bool matches(NetworkRoute route) {
|
||||
return switch (this) {
|
||||
NetworkFilter.all => true,
|
||||
NetworkFilter.overlapping => route.overlapping,
|
||||
NetworkFilter.exitNode => route.isExitNode,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class ClientSnapshot {
|
||||
const ClientSnapshot({
|
||||
required this.daemonAddr,
|
||||
required this.daemonVersion,
|
||||
required this.status,
|
||||
required this.activeProfile,
|
||||
required this.profiles,
|
||||
required this.networks,
|
||||
required this.settings,
|
||||
this.errorMessage,
|
||||
this.pendingLogin,
|
||||
});
|
||||
|
||||
factory ClientSnapshot.initial(String daemonAddr) {
|
||||
return ClientSnapshot(
|
||||
daemonAddr: daemonAddr,
|
||||
daemonVersion: 'unknown',
|
||||
status: ConnectionStatus.disconnected,
|
||||
activeProfile: const ProfileInfo(name: 'default', active: true),
|
||||
profiles: const [ProfileInfo(name: 'default', active: true)],
|
||||
networks: const [],
|
||||
settings: const ClientSettings(),
|
||||
);
|
||||
}
|
||||
|
||||
final String daemonAddr;
|
||||
final String daemonVersion;
|
||||
final ConnectionStatus status;
|
||||
final ProfileInfo activeProfile;
|
||||
final List<ProfileInfo> profiles;
|
||||
final List<NetworkRoute> networks;
|
||||
final ClientSettings settings;
|
||||
final String? errorMessage;
|
||||
final PendingLogin? pendingLogin;
|
||||
|
||||
ClientSnapshot copyWith({
|
||||
String? daemonAddr,
|
||||
String? daemonVersion,
|
||||
ConnectionStatus? status,
|
||||
ProfileInfo? activeProfile,
|
||||
List<ProfileInfo>? profiles,
|
||||
List<NetworkRoute>? networks,
|
||||
ClientSettings? settings,
|
||||
String? errorMessage,
|
||||
PendingLogin? pendingLogin,
|
||||
bool clearError = false,
|
||||
bool clearPendingLogin = false,
|
||||
}) {
|
||||
return ClientSnapshot(
|
||||
daemonAddr: daemonAddr ?? this.daemonAddr,
|
||||
daemonVersion: daemonVersion ?? this.daemonVersion,
|
||||
status: status ?? this.status,
|
||||
activeProfile: activeProfile ?? this.activeProfile,
|
||||
profiles: profiles ?? this.profiles,
|
||||
networks: networks ?? this.networks,
|
||||
settings: settings ?? this.settings,
|
||||
errorMessage: clearError ? null : errorMessage ?? this.errorMessage,
|
||||
pendingLogin: clearPendingLogin
|
||||
? null
|
||||
: pendingLogin ?? this.pendingLogin,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class PendingLogin {
|
||||
const PendingLogin({
|
||||
required this.verificationUri,
|
||||
required this.userCode,
|
||||
});
|
||||
|
||||
final String verificationUri;
|
||||
final String userCode;
|
||||
}
|
||||
|
||||
class ProfileInfo {
|
||||
const ProfileInfo({required this.name, required this.active, this.email});
|
||||
|
||||
final String name;
|
||||
final String? email;
|
||||
final bool active;
|
||||
}
|
||||
|
||||
class NetworkRoute {
|
||||
const NetworkRoute({
|
||||
required this.id,
|
||||
required this.range,
|
||||
this.domains = const [],
|
||||
this.resolvedIps = const {},
|
||||
this.selected = false,
|
||||
this.overlapping = false,
|
||||
});
|
||||
|
||||
final String id;
|
||||
final String range;
|
||||
final List<String> domains;
|
||||
final Map<String, List<String>> resolvedIps;
|
||||
final bool selected;
|
||||
final bool overlapping;
|
||||
|
||||
bool get isExitNode => range == '0.0.0.0/0';
|
||||
}
|
||||
|
||||
enum DaemonLogLevel { unknown, panic, fatal, error, warn, info, debug, trace }
|
||||
|
||||
class DebugBundleResult {
|
||||
const DebugBundleResult({
|
||||
required this.path,
|
||||
this.uploadedKey = '',
|
||||
this.uploadFailureReason = '',
|
||||
});
|
||||
|
||||
final String path;
|
||||
final String uploadedKey;
|
||||
final String uploadFailureReason;
|
||||
|
||||
bool get uploaded => uploadedKey.isNotEmpty && uploadFailureReason.isEmpty;
|
||||
bool get uploadFailed => uploadFailureReason.isNotEmpty;
|
||||
}
|
||||
|
||||
class TriggerUpdateResult {
|
||||
const TriggerUpdateResult({required this.success, this.errorMessage = ''});
|
||||
|
||||
final bool success;
|
||||
final String errorMessage;
|
||||
}
|
||||
|
||||
class InstallerResult {
|
||||
const InstallerResult({required this.success, this.errorMessage = ''});
|
||||
|
||||
final bool success;
|
||||
final String errorMessage;
|
||||
}
|
||||
|
||||
class UpdateProgressEvent {
|
||||
const UpdateProgressEvent({required this.version});
|
||||
final String version;
|
||||
}
|
||||
|
||||
enum NotificationSeverity { info, warning, error, critical }
|
||||
|
||||
enum NotificationCategory {
|
||||
network,
|
||||
dns,
|
||||
authentication,
|
||||
connectivity,
|
||||
system;
|
||||
|
||||
String get label {
|
||||
return switch (this) {
|
||||
NotificationCategory.network => 'Network',
|
||||
NotificationCategory.dns => 'DNS',
|
||||
NotificationCategory.authentication => 'Authentication',
|
||||
NotificationCategory.connectivity => 'Connectivity',
|
||||
NotificationCategory.system => 'System',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class SystemNotification {
|
||||
const SystemNotification({
|
||||
required this.severity,
|
||||
required this.category,
|
||||
required this.message,
|
||||
required this.userMessage,
|
||||
this.id,
|
||||
});
|
||||
|
||||
final NotificationSeverity severity;
|
||||
final NotificationCategory category;
|
||||
final String message;
|
||||
final String userMessage;
|
||||
final String? id;
|
||||
}
|
||||
|
||||
class ClientSettings {
|
||||
const ClientSettings({
|
||||
this.managementUrl = 'https://api.netbird.io',
|
||||
this.interfaceName = 'wt0',
|
||||
this.wireguardPort = 51820,
|
||||
this.mtu = 1280,
|
||||
this.autoConnect = true,
|
||||
this.allowSsh = false,
|
||||
this.quantumResistance = false,
|
||||
this.notifications = true,
|
||||
this.lazyConnection = false,
|
||||
this.blockInbound = false,
|
||||
});
|
||||
|
||||
final String managementUrl;
|
||||
final String interfaceName;
|
||||
final int wireguardPort;
|
||||
final int mtu;
|
||||
final bool autoConnect;
|
||||
final bool allowSsh;
|
||||
final bool quantumResistance;
|
||||
final bool notifications;
|
||||
final bool lazyConnection;
|
||||
final bool blockInbound;
|
||||
|
||||
ClientSettings copyWith({
|
||||
String? managementUrl,
|
||||
String? interfaceName,
|
||||
int? wireguardPort,
|
||||
int? mtu,
|
||||
bool? autoConnect,
|
||||
bool? allowSsh,
|
||||
bool? quantumResistance,
|
||||
bool? notifications,
|
||||
bool? lazyConnection,
|
||||
bool? blockInbound,
|
||||
}) {
|
||||
return ClientSettings(
|
||||
managementUrl: managementUrl ?? this.managementUrl,
|
||||
interfaceName: interfaceName ?? this.interfaceName,
|
||||
wireguardPort: wireguardPort ?? this.wireguardPort,
|
||||
mtu: mtu ?? this.mtu,
|
||||
autoConnect: autoConnect ?? this.autoConnect,
|
||||
allowSsh: allowSsh ?? this.allowSsh,
|
||||
quantumResistance: quantumResistance ?? this.quantumResistance,
|
||||
notifications: notifications ?? this.notifications,
|
||||
lazyConnection: lazyConnection ?? this.lazyConnection,
|
||||
blockInbound: blockInbound ?? this.blockInbound,
|
||||
);
|
||||
}
|
||||
}
|
||||
22
client/flutter_ui/lib/src/platform.dart
Normal file
@@ -0,0 +1,22 @@
|
||||
import 'dart:io';
|
||||
|
||||
/// Opens a URL in the user's default browser. Returns false if the platform
|
||||
/// helper exits non-zero or is missing. Mirrors the Go UI's `openURL` logic.
|
||||
Future<bool> openExternalUrl(String url) async {
|
||||
try {
|
||||
final ProcessResult result;
|
||||
if (Platform.isMacOS) {
|
||||
result = await Process.run('open', [url]);
|
||||
} else if (Platform.isWindows) {
|
||||
result = await Process.run('rundll32', [
|
||||
'url.dll,FileProtocolHandler',
|
||||
url,
|
||||
]);
|
||||
} else {
|
||||
result = await Process.run('xdg-open', [url]);
|
||||
}
|
||||
return result.exitCode == 0;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
140
client/flutter_ui/lib/src/update_progress.dart
Normal file
@@ -0,0 +1,140 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'daemon_client.dart';
|
||||
import 'models.dart';
|
||||
|
||||
const _allowCloseAfter = Duration(seconds: 10);
|
||||
const _dotInterval = Duration(seconds: 1);
|
||||
|
||||
/// Shows a modal dialog while the daemon installs an update. Polls
|
||||
/// `GetInstallerResult` and resolves when the daemon finishes or fails.
|
||||
Future<void> showUpdateProgressDialog({
|
||||
required BuildContext context,
|
||||
required DaemonClient client,
|
||||
required UpdateProgressEvent event,
|
||||
}) {
|
||||
return showDialog<void>(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (context) => _UpdateProgressDialog(client: client, event: event),
|
||||
);
|
||||
}
|
||||
|
||||
class _UpdateProgressDialog extends StatefulWidget {
|
||||
const _UpdateProgressDialog({required this.client, required this.event});
|
||||
|
||||
final DaemonClient client;
|
||||
final UpdateProgressEvent event;
|
||||
|
||||
@override
|
||||
State<_UpdateProgressDialog> createState() => _UpdateProgressDialogState();
|
||||
}
|
||||
|
||||
class _UpdateProgressDialogState extends State<_UpdateProgressDialog> {
|
||||
Timer? _dotTimer;
|
||||
Timer? _allowCloseTimer;
|
||||
int _dots = 0;
|
||||
bool _canClose = false;
|
||||
String _status = 'Updating';
|
||||
String? _error;
|
||||
bool _resolved = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_dotTimer = Timer.periodic(_dotInterval, (_) => _tick());
|
||||
_allowCloseTimer = Timer(_allowCloseAfter, () {
|
||||
if (mounted) {
|
||||
setState(() => _canClose = true);
|
||||
}
|
||||
});
|
||||
unawaited(_pollInstaller());
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_dotTimer?.cancel();
|
||||
_allowCloseTimer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _tick() {
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_dots = (_dots + 1) % 4;
|
||||
_status = 'Updating${'.' * _dots}';
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _pollInstaller() async {
|
||||
try {
|
||||
final result = await widget.client.getInstallerResult();
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
if (result.success) {
|
||||
Navigator.of(context).pop();
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_resolved = true;
|
||||
_canClose = true;
|
||||
_status = 'Update failed';
|
||||
_error = result.errorMessage.isEmpty
|
||||
? 'Unknown error'
|
||||
: result.errorMessage;
|
||||
});
|
||||
} catch (error) {
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_resolved = true;
|
||||
_canClose = true;
|
||||
_status = 'Update timed out';
|
||||
_error = error.toString();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return PopScope(
|
||||
canPop: _canClose,
|
||||
child: AlertDialog(
|
||||
title: const Text('Updating client'),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Your client version is older than the auto-update version set in '
|
||||
'Management.\nUpdating client to ${widget.event.version}.',
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
if (!_resolved) const LinearProgressIndicator(),
|
||||
const SizedBox(height: 12),
|
||||
Text(_status),
|
||||
if (_error != null) ...[
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
_error!,
|
||||
style: TextStyle(color: Theme.of(context).colorScheme.error),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: _canClose ? () => Navigator.of(context).pop() : null,
|
||||
child: const Text('Close'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
1
client/flutter_ui/linux/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
flutter/ephemeral
|
||||
128
client/flutter_ui/linux/CMakeLists.txt
Normal file
@@ -0,0 +1,128 @@
|
||||
# Project-level configuration.
|
||||
cmake_minimum_required(VERSION 3.13)
|
||||
project(runner LANGUAGES CXX)
|
||||
|
||||
# The name of the executable created for the application. Change this to change
|
||||
# the on-disk name of your application.
|
||||
set(BINARY_NAME "netbird_flutter_ui")
|
||||
# The unique GTK application identifier for this application. See:
|
||||
# https://wiki.gnome.org/HowDoI/ChooseApplicationID
|
||||
set(APPLICATION_ID "io.netbird.netbird_flutter_ui")
|
||||
|
||||
# Explicitly opt in to modern CMake behaviors to avoid warnings with recent
|
||||
# versions of CMake.
|
||||
cmake_policy(SET CMP0063 NEW)
|
||||
|
||||
# Load bundled libraries from the lib/ directory relative to the binary.
|
||||
set(CMAKE_INSTALL_RPATH "$ORIGIN/lib")
|
||||
|
||||
# Root filesystem for cross-building.
|
||||
if(FLUTTER_TARGET_PLATFORM_SYSROOT)
|
||||
set(CMAKE_SYSROOT ${FLUTTER_TARGET_PLATFORM_SYSROOT})
|
||||
set(CMAKE_FIND_ROOT_PATH ${CMAKE_SYSROOT})
|
||||
set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER)
|
||||
set(CMAKE_FIND_ROOT_PATH_MODE_PACKAGE ONLY)
|
||||
set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY)
|
||||
set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY)
|
||||
endif()
|
||||
|
||||
# Define build configuration options.
|
||||
if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES)
|
||||
set(CMAKE_BUILD_TYPE "Debug" CACHE
|
||||
STRING "Flutter build mode" FORCE)
|
||||
set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS
|
||||
"Debug" "Profile" "Release")
|
||||
endif()
|
||||
|
||||
# Compilation settings that should be applied to most targets.
|
||||
#
|
||||
# Be cautious about adding new options here, as plugins use this function by
|
||||
# default. In most cases, you should add new options to specific targets instead
|
||||
# of modifying this function.
|
||||
function(APPLY_STANDARD_SETTINGS TARGET)
|
||||
target_compile_features(${TARGET} PUBLIC cxx_std_14)
|
||||
target_compile_options(${TARGET} PRIVATE -Wall -Werror)
|
||||
target_compile_options(${TARGET} PRIVATE "$<$<NOT:$<CONFIG:Debug>>:-O3>")
|
||||
target_compile_definitions(${TARGET} PRIVATE "$<$<NOT:$<CONFIG:Debug>>:NDEBUG>")
|
||||
endfunction()
|
||||
|
||||
# Flutter library and tool build rules.
|
||||
set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter")
|
||||
add_subdirectory(${FLUTTER_MANAGED_DIR})
|
||||
|
||||
# System-level dependencies.
|
||||
find_package(PkgConfig REQUIRED)
|
||||
pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0)
|
||||
|
||||
# Application build; see runner/CMakeLists.txt.
|
||||
add_subdirectory("runner")
|
||||
|
||||
# Run the Flutter tool portions of the build. This must not be removed.
|
||||
add_dependencies(${BINARY_NAME} flutter_assemble)
|
||||
|
||||
# Only the install-generated bundle's copy of the executable will launch
|
||||
# correctly, since the resources must in the right relative locations. To avoid
|
||||
# people trying to run the unbundled copy, put it in a subdirectory instead of
|
||||
# the default top-level location.
|
||||
set_target_properties(${BINARY_NAME}
|
||||
PROPERTIES
|
||||
RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/intermediates_do_not_run"
|
||||
)
|
||||
|
||||
|
||||
# Generated plugin build rules, which manage building the plugins and adding
|
||||
# them to the application.
|
||||
include(flutter/generated_plugins.cmake)
|
||||
|
||||
|
||||
# === Installation ===
|
||||
# By default, "installing" just makes a relocatable bundle in the build
|
||||
# directory.
|
||||
set(BUILD_BUNDLE_DIR "${PROJECT_BINARY_DIR}/bundle")
|
||||
if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT)
|
||||
set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE)
|
||||
endif()
|
||||
|
||||
# Start with a clean build bundle directory every time.
|
||||
install(CODE "
|
||||
file(REMOVE_RECURSE \"${BUILD_BUNDLE_DIR}/\")
|
||||
" COMPONENT Runtime)
|
||||
|
||||
set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data")
|
||||
set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}/lib")
|
||||
|
||||
install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}"
|
||||
COMPONENT Runtime)
|
||||
|
||||
install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}"
|
||||
COMPONENT Runtime)
|
||||
|
||||
install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}"
|
||||
COMPONENT Runtime)
|
||||
|
||||
foreach(bundled_library ${PLUGIN_BUNDLED_LIBRARIES})
|
||||
install(FILES "${bundled_library}"
|
||||
DESTINATION "${INSTALL_BUNDLE_LIB_DIR}"
|
||||
COMPONENT Runtime)
|
||||
endforeach(bundled_library)
|
||||
|
||||
# Copy the native assets provided by the build.dart from all packages.
|
||||
set(NATIVE_ASSETS_DIR "${PROJECT_BUILD_DIR}native_assets/linux/")
|
||||
install(DIRECTORY "${NATIVE_ASSETS_DIR}"
|
||||
DESTINATION "${INSTALL_BUNDLE_LIB_DIR}"
|
||||
COMPONENT Runtime)
|
||||
|
||||
# Fully re-copy the assets directory on each build to avoid having stale files
|
||||
# from a previous install.
|
||||
set(FLUTTER_ASSET_DIR_NAME "flutter_assets")
|
||||
install(CODE "
|
||||
file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\")
|
||||
" COMPONENT Runtime)
|
||||
install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}"
|
||||
DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime)
|
||||
|
||||
# Install the AOT library on non-Debug builds only.
|
||||
if(NOT CMAKE_BUILD_TYPE MATCHES "Debug")
|
||||
install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}"
|
||||
COMPONENT Runtime)
|
||||
endif()
|
||||
88
client/flutter_ui/linux/flutter/CMakeLists.txt
Normal file
@@ -0,0 +1,88 @@
|
||||
# This file controls Flutter-level build steps. It should not be edited.
|
||||
cmake_minimum_required(VERSION 3.10)
|
||||
|
||||
set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral")
|
||||
|
||||
# Configuration provided via flutter tool.
|
||||
include(${EPHEMERAL_DIR}/generated_config.cmake)
|
||||
|
||||
# TODO: Move the rest of this into files in ephemeral. See
|
||||
# https://github.com/flutter/flutter/issues/57146.
|
||||
|
||||
# Serves the same purpose as list(TRANSFORM ... PREPEND ...),
|
||||
# which isn't available in 3.10.
|
||||
function(list_prepend LIST_NAME PREFIX)
|
||||
set(NEW_LIST "")
|
||||
foreach(element ${${LIST_NAME}})
|
||||
list(APPEND NEW_LIST "${PREFIX}${element}")
|
||||
endforeach(element)
|
||||
set(${LIST_NAME} "${NEW_LIST}" PARENT_SCOPE)
|
||||
endfunction()
|
||||
|
||||
# === Flutter Library ===
|
||||
# System-level dependencies.
|
||||
find_package(PkgConfig REQUIRED)
|
||||
pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0)
|
||||
pkg_check_modules(GLIB REQUIRED IMPORTED_TARGET glib-2.0)
|
||||
pkg_check_modules(GIO REQUIRED IMPORTED_TARGET gio-2.0)
|
||||
|
||||
set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/libflutter_linux_gtk.so")
|
||||
|
||||
# Published to parent scope for install step.
|
||||
set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE)
|
||||
set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE)
|
||||
set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE)
|
||||
set(AOT_LIBRARY "${PROJECT_DIR}/build/lib/libapp.so" PARENT_SCOPE)
|
||||
|
||||
list(APPEND FLUTTER_LIBRARY_HEADERS
|
||||
"fl_basic_message_channel.h"
|
||||
"fl_binary_codec.h"
|
||||
"fl_binary_messenger.h"
|
||||
"fl_dart_project.h"
|
||||
"fl_engine.h"
|
||||
"fl_json_message_codec.h"
|
||||
"fl_json_method_codec.h"
|
||||
"fl_message_codec.h"
|
||||
"fl_method_call.h"
|
||||
"fl_method_channel.h"
|
||||
"fl_method_codec.h"
|
||||
"fl_method_response.h"
|
||||
"fl_plugin_registrar.h"
|
||||
"fl_plugin_registry.h"
|
||||
"fl_standard_message_codec.h"
|
||||
"fl_standard_method_codec.h"
|
||||
"fl_string_codec.h"
|
||||
"fl_value.h"
|
||||
"fl_view.h"
|
||||
"flutter_linux.h"
|
||||
)
|
||||
list_prepend(FLUTTER_LIBRARY_HEADERS "${EPHEMERAL_DIR}/flutter_linux/")
|
||||
add_library(flutter INTERFACE)
|
||||
target_include_directories(flutter INTERFACE
|
||||
"${EPHEMERAL_DIR}"
|
||||
)
|
||||
target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}")
|
||||
target_link_libraries(flutter INTERFACE
|
||||
PkgConfig::GTK
|
||||
PkgConfig::GLIB
|
||||
PkgConfig::GIO
|
||||
)
|
||||
add_dependencies(flutter flutter_assemble)
|
||||
|
||||
# === Flutter tool backend ===
|
||||
# _phony_ is a non-existent file to force this command to run every time,
|
||||
# since currently there's no way to get a full input/output list from the
|
||||
# flutter tool.
|
||||
add_custom_command(
|
||||
OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS}
|
||||
${CMAKE_CURRENT_BINARY_DIR}/_phony_
|
||||
COMMAND ${CMAKE_COMMAND} -E env
|
||||
${FLUTTER_TOOL_ENVIRONMENT}
|
||||
"${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.sh"
|
||||
${FLUTTER_TARGET_PLATFORM} ${CMAKE_BUILD_TYPE}
|
||||
VERBATIM
|
||||
)
|
||||
add_custom_target(flutter_assemble DEPENDS
|
||||
"${FLUTTER_LIBRARY}"
|
||||
${FLUTTER_LIBRARY_HEADERS}
|
||||
)
|
||||
@@ -0,0 +1,27 @@
|
||||
//
|
||||
// Generated file. Do not edit.
|
||||
//
|
||||
|
||||
// clang-format off
|
||||
|
||||
#include "generated_plugin_registrant.h"
|
||||
|
||||
#include <local_notifier/local_notifier_plugin.h>
|
||||
#include <screen_retriever_linux/screen_retriever_linux_plugin.h>
|
||||
#include <tray_manager/tray_manager_plugin.h>
|
||||
#include <window_manager/window_manager_plugin.h>
|
||||
|
||||
void fl_register_plugins(FlPluginRegistry* registry) {
|
||||
g_autoptr(FlPluginRegistrar) local_notifier_registrar =
|
||||
fl_plugin_registry_get_registrar_for_plugin(registry, "LocalNotifierPlugin");
|
||||
local_notifier_plugin_register_with_registrar(local_notifier_registrar);
|
||||
g_autoptr(FlPluginRegistrar) screen_retriever_linux_registrar =
|
||||
fl_plugin_registry_get_registrar_for_plugin(registry, "ScreenRetrieverLinuxPlugin");
|
||||
screen_retriever_linux_plugin_register_with_registrar(screen_retriever_linux_registrar);
|
||||
g_autoptr(FlPluginRegistrar) tray_manager_registrar =
|
||||
fl_plugin_registry_get_registrar_for_plugin(registry, "TrayManagerPlugin");
|
||||
tray_manager_plugin_register_with_registrar(tray_manager_registrar);
|
||||
g_autoptr(FlPluginRegistrar) window_manager_registrar =
|
||||
fl_plugin_registry_get_registrar_for_plugin(registry, "WindowManagerPlugin");
|
||||
window_manager_plugin_register_with_registrar(window_manager_registrar);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
//
|
||||
// Generated file. Do not edit.
|
||||
//
|
||||
|
||||
// clang-format off
|
||||
|
||||
#ifndef GENERATED_PLUGIN_REGISTRANT_
|
||||
#define GENERATED_PLUGIN_REGISTRANT_
|
||||
|
||||
#include <flutter_linux/flutter_linux.h>
|
||||
|
||||
// Registers Flutter plugins.
|
||||
void fl_register_plugins(FlPluginRegistry* registry);
|
||||
|
||||
#endif // GENERATED_PLUGIN_REGISTRANT_
|
||||
27
client/flutter_ui/linux/flutter/generated_plugins.cmake
Normal file
@@ -0,0 +1,27 @@
|
||||
#
|
||||
# Generated file, do not edit.
|
||||
#
|
||||
|
||||
list(APPEND FLUTTER_PLUGIN_LIST
|
||||
local_notifier
|
||||
screen_retriever_linux
|
||||
tray_manager
|
||||
window_manager
|
||||
)
|
||||
|
||||
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
||||
)
|
||||
|
||||
set(PLUGIN_BUNDLED_LIBRARIES)
|
||||
|
||||
foreach(plugin ${FLUTTER_PLUGIN_LIST})
|
||||
add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/linux plugins/${plugin})
|
||||
target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin)
|
||||
list(APPEND PLUGIN_BUNDLED_LIBRARIES $<TARGET_FILE:${plugin}_plugin>)
|
||||
list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries})
|
||||
endforeach(plugin)
|
||||
|
||||
foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST})
|
||||
add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/linux plugins/${ffi_plugin})
|
||||
list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries})
|
||||
endforeach(ffi_plugin)
|
||||
26
client/flutter_ui/linux/runner/CMakeLists.txt
Normal file
@@ -0,0 +1,26 @@
|
||||
cmake_minimum_required(VERSION 3.13)
|
||||
project(runner LANGUAGES CXX)
|
||||
|
||||
# Define the application target. To change its name, change BINARY_NAME in the
|
||||
# top-level CMakeLists.txt, not the value here, or `flutter run` will no longer
|
||||
# work.
|
||||
#
|
||||
# Any new source files that you add to the application should be added here.
|
||||
add_executable(${BINARY_NAME}
|
||||
"main.cc"
|
||||
"my_application.cc"
|
||||
"${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc"
|
||||
)
|
||||
|
||||
# Apply the standard set of build settings. This can be removed for applications
|
||||
# that need different build settings.
|
||||
apply_standard_settings(${BINARY_NAME})
|
||||
|
||||
# Add preprocessor definitions for the application ID.
|
||||
add_definitions(-DAPPLICATION_ID="${APPLICATION_ID}")
|
||||
|
||||
# Add dependency libraries. Add any application-specific dependencies here.
|
||||
target_link_libraries(${BINARY_NAME} PRIVATE flutter)
|
||||
target_link_libraries(${BINARY_NAME} PRIVATE PkgConfig::GTK)
|
||||
|
||||
target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}")
|
||||
6
client/flutter_ui/linux/runner/main.cc
Normal file
@@ -0,0 +1,6 @@
|
||||
#include "my_application.h"
|
||||
|
||||
int main(int argc, char** argv) {
|
||||
g_autoptr(MyApplication) app = my_application_new();
|
||||
return g_application_run(G_APPLICATION(app), argc, argv);
|
||||
}
|
||||
148
client/flutter_ui/linux/runner/my_application.cc
Normal file
@@ -0,0 +1,148 @@
|
||||
#include "my_application.h"
|
||||
|
||||
#include <flutter_linux/flutter_linux.h>
|
||||
#ifdef GDK_WINDOWING_X11
|
||||
#include <gdk/gdkx.h>
|
||||
#endif
|
||||
|
||||
#include "flutter/generated_plugin_registrant.h"
|
||||
|
||||
struct _MyApplication {
|
||||
GtkApplication parent_instance;
|
||||
char** dart_entrypoint_arguments;
|
||||
};
|
||||
|
||||
G_DEFINE_TYPE(MyApplication, my_application, GTK_TYPE_APPLICATION)
|
||||
|
||||
// Called when first Flutter frame received.
|
||||
static void first_frame_cb(MyApplication* self, FlView* view) {
|
||||
gtk_widget_show(gtk_widget_get_toplevel(GTK_WIDGET(view)));
|
||||
}
|
||||
|
||||
// Implements GApplication::activate.
|
||||
static void my_application_activate(GApplication* application) {
|
||||
MyApplication* self = MY_APPLICATION(application);
|
||||
GtkWindow* window =
|
||||
GTK_WINDOW(gtk_application_window_new(GTK_APPLICATION(application)));
|
||||
|
||||
// Use a header bar when running in GNOME as this is the common style used
|
||||
// by applications and is the setup most users will be using (e.g. Ubuntu
|
||||
// desktop).
|
||||
// If running on X and not using GNOME then just use a traditional title bar
|
||||
// in case the window manager does more exotic layout, e.g. tiling.
|
||||
// If running on Wayland assume the header bar will work (may need changing
|
||||
// if future cases occur).
|
||||
gboolean use_header_bar = TRUE;
|
||||
#ifdef GDK_WINDOWING_X11
|
||||
GdkScreen* screen = gtk_window_get_screen(window);
|
||||
if (GDK_IS_X11_SCREEN(screen)) {
|
||||
const gchar* wm_name = gdk_x11_screen_get_window_manager_name(screen);
|
||||
if (g_strcmp0(wm_name, "GNOME Shell") != 0) {
|
||||
use_header_bar = FALSE;
|
||||
}
|
||||
}
|
||||
#endif
|
||||
if (use_header_bar) {
|
||||
GtkHeaderBar* header_bar = GTK_HEADER_BAR(gtk_header_bar_new());
|
||||
gtk_widget_show(GTK_WIDGET(header_bar));
|
||||
gtk_header_bar_set_title(header_bar, "netbird_flutter_ui");
|
||||
gtk_header_bar_set_show_close_button(header_bar, TRUE);
|
||||
gtk_window_set_titlebar(window, GTK_WIDGET(header_bar));
|
||||
} else {
|
||||
gtk_window_set_title(window, "netbird_flutter_ui");
|
||||
}
|
||||
|
||||
gtk_window_set_default_size(window, 1280, 720);
|
||||
|
||||
g_autoptr(FlDartProject) project = fl_dart_project_new();
|
||||
fl_dart_project_set_dart_entrypoint_arguments(
|
||||
project, self->dart_entrypoint_arguments);
|
||||
|
||||
FlView* view = fl_view_new(project);
|
||||
GdkRGBA background_color;
|
||||
// Background defaults to black, override it here if necessary, e.g. #00000000
|
||||
// for transparent.
|
||||
gdk_rgba_parse(&background_color, "#000000");
|
||||
fl_view_set_background_color(view, &background_color);
|
||||
gtk_widget_show(GTK_WIDGET(view));
|
||||
gtk_container_add(GTK_CONTAINER(window), GTK_WIDGET(view));
|
||||
|
||||
// Show the window when Flutter renders.
|
||||
// Requires the view to be realized so we can start rendering.
|
||||
g_signal_connect_swapped(view, "first-frame", G_CALLBACK(first_frame_cb),
|
||||
self);
|
||||
gtk_widget_realize(GTK_WIDGET(view));
|
||||
|
||||
fl_register_plugins(FL_PLUGIN_REGISTRY(view));
|
||||
|
||||
gtk_widget_grab_focus(GTK_WIDGET(view));
|
||||
}
|
||||
|
||||
// Implements GApplication::local_command_line.
|
||||
static gboolean my_application_local_command_line(GApplication* application,
|
||||
gchar*** arguments,
|
||||
int* exit_status) {
|
||||
MyApplication* self = MY_APPLICATION(application);
|
||||
// Strip out the first argument as it is the binary name.
|
||||
self->dart_entrypoint_arguments = g_strdupv(*arguments + 1);
|
||||
|
||||
g_autoptr(GError) error = nullptr;
|
||||
if (!g_application_register(application, nullptr, &error)) {
|
||||
g_warning("Failed to register: %s", error->message);
|
||||
*exit_status = 1;
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
g_application_activate(application);
|
||||
*exit_status = 0;
|
||||
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
// Implements GApplication::startup.
|
||||
static void my_application_startup(GApplication* application) {
|
||||
// MyApplication* self = MY_APPLICATION(object);
|
||||
|
||||
// Perform any actions required at application startup.
|
||||
|
||||
G_APPLICATION_CLASS(my_application_parent_class)->startup(application);
|
||||
}
|
||||
|
||||
// Implements GApplication::shutdown.
|
||||
static void my_application_shutdown(GApplication* application) {
|
||||
// MyApplication* self = MY_APPLICATION(object);
|
||||
|
||||
// Perform any actions required at application shutdown.
|
||||
|
||||
G_APPLICATION_CLASS(my_application_parent_class)->shutdown(application);
|
||||
}
|
||||
|
||||
// Implements GObject::dispose.
|
||||
static void my_application_dispose(GObject* object) {
|
||||
MyApplication* self = MY_APPLICATION(object);
|
||||
g_clear_pointer(&self->dart_entrypoint_arguments, g_strfreev);
|
||||
G_OBJECT_CLASS(my_application_parent_class)->dispose(object);
|
||||
}
|
||||
|
||||
static void my_application_class_init(MyApplicationClass* klass) {
|
||||
G_APPLICATION_CLASS(klass)->activate = my_application_activate;
|
||||
G_APPLICATION_CLASS(klass)->local_command_line =
|
||||
my_application_local_command_line;
|
||||
G_APPLICATION_CLASS(klass)->startup = my_application_startup;
|
||||
G_APPLICATION_CLASS(klass)->shutdown = my_application_shutdown;
|
||||
G_OBJECT_CLASS(klass)->dispose = my_application_dispose;
|
||||
}
|
||||
|
||||
static void my_application_init(MyApplication* self) {}
|
||||
|
||||
MyApplication* my_application_new() {
|
||||
// Set the program name to the application ID, which helps various systems
|
||||
// like GTK and desktop environments map this running application to its
|
||||
// corresponding .desktop file. This ensures better integration by allowing
|
||||
// the application to be recognized beyond its binary name.
|
||||
g_set_prgname(APPLICATION_ID);
|
||||
|
||||
return MY_APPLICATION(g_object_new(my_application_get_type(),
|
||||
"application-id", APPLICATION_ID, "flags",
|
||||
G_APPLICATION_NON_UNIQUE, nullptr));
|
||||
}
|
||||
21
client/flutter_ui/linux/runner/my_application.h
Normal file
@@ -0,0 +1,21 @@
|
||||
#ifndef FLUTTER_MY_APPLICATION_H_
|
||||
#define FLUTTER_MY_APPLICATION_H_
|
||||
|
||||
#include <gtk/gtk.h>
|
||||
|
||||
G_DECLARE_FINAL_TYPE(MyApplication,
|
||||
my_application,
|
||||
MY,
|
||||
APPLICATION,
|
||||
GtkApplication)
|
||||
|
||||
/**
|
||||
* my_application_new:
|
||||
*
|
||||
* Creates a new Flutter-based application.
|
||||
*
|
||||
* Returns: a new #MyApplication.
|
||||
*/
|
||||
MyApplication* my_application_new();
|
||||
|
||||
#endif // FLUTTER_MY_APPLICATION_H_
|
||||
7
client/flutter_ui/macos/.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
# Flutter-related
|
||||
**/Flutter/ephemeral/
|
||||
**/Pods/
|
||||
|
||||
# Xcode-related
|
||||
**/dgph
|
||||
**/xcuserdata/
|
||||
2
client/flutter_ui/macos/Flutter/Flutter-Debug.xcconfig
Normal file
@@ -0,0 +1,2 @@
|
||||
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"
|
||||
#include "ephemeral/Flutter-Generated.xcconfig"
|
||||
2
client/flutter_ui/macos/Flutter/Flutter-Release.xcconfig
Normal file
@@ -0,0 +1,2 @@
|
||||
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"
|
||||
#include "ephemeral/Flutter-Generated.xcconfig"
|
||||