mirror of
https://github.com/netbirdio/netbird.git
synced 2026-08-17 21:21:28 +02:00
Compare commits
110 Commits
reverse-pr
...
android-in
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
faf8619a83 | ||
|
|
eba86a3007 | ||
|
|
3d7b25a02c | ||
|
|
73cffdb702 | ||
|
|
14aab0fc6e | ||
|
|
f06b8c7624 | ||
|
|
ee3aeadf8f | ||
|
|
c28cf2fa61 | ||
|
|
78c95bb8ec | ||
|
|
1aa1f915a2 | ||
|
|
0bb49fa144 | ||
|
|
2cfe14d7ec | ||
|
|
ceb1719f9a | ||
|
|
2da4512272 | ||
|
|
85dd335836 | ||
|
|
5544761b47 | ||
|
|
1d372bb634 | ||
|
|
cfd3a231e2 | ||
|
|
31e558a64d | ||
|
|
f986b33b92 | ||
|
|
e290769df1 | ||
|
|
58c09ead21 | ||
|
|
c5503fdc7f | ||
|
|
6b69f5c05d | ||
|
|
db9fcf39ef | ||
|
|
52faa202b2 | ||
|
|
f5ce0bc65a | ||
|
|
16f7e1e148 | ||
|
|
9531c9cf79 | ||
|
|
ba16475ad6 | ||
|
|
fc52cdcedd | ||
|
|
a98ced399e | ||
|
|
b4cc8a16d9 | ||
|
|
77e5ac776b | ||
|
|
3c973c98e3 | ||
|
|
12546e231c | ||
|
|
1fcb6d4bed | ||
|
|
7612b4d299 | ||
|
|
ecce1aa2a1 | ||
|
|
53f2518af1 | ||
|
|
71a4276426 | ||
|
|
052cf5a748 | ||
|
|
71bfc73cd1 | ||
|
|
77e7d82d5a | ||
|
|
e3e5a25605 | ||
|
|
a8d2e5b0b2 | ||
|
|
cc0702396c | ||
|
|
6a83476831 | ||
|
|
95a458801c | ||
|
|
14f9f8ce22 | ||
|
|
f805c149d9 | ||
|
|
c4c8e2fe1e | ||
|
|
a33e981c26 | ||
|
|
99048e2bf2 | ||
|
|
c1c8ee832e | ||
|
|
9ee5c04687 | ||
|
|
072fa8143b | ||
|
|
8fb3e707af | ||
|
|
902263ac96 | ||
|
|
27b2d3f351 | ||
|
|
ebfdf7d7b8 | ||
|
|
e8671a811d | ||
|
|
1ca26d8faa | ||
|
|
f9abe2727f | ||
|
|
664a3d026c | ||
|
|
d2c961f67c | ||
|
|
5584f8ef0a | ||
|
|
26f7ed858d | ||
|
|
82e799f095 | ||
|
|
f65f7b347e | ||
|
|
179e8f6e13 | ||
|
|
2ee21d2b5c | ||
|
|
eb619fc7e3 | ||
|
|
8632a0d215 | ||
|
|
f63fd21e0c | ||
|
|
524b8b9718 | ||
|
|
2ce6323602 | ||
|
|
9a05a1c698 | ||
|
|
5dd914782a | ||
|
|
8c19b7a30a | ||
|
|
1e2a7aa571 | ||
|
|
98f8a24108 | ||
|
|
b2ae98f0fd | ||
|
|
40ac64bb4e | ||
|
|
19a6cedfff | ||
|
|
6526fc2bec | ||
|
|
2afa69b622 | ||
|
|
2a61eac047 | ||
|
|
f2d13b884a | ||
|
|
564595d283 | ||
|
|
78c1c2fc32 | ||
|
|
bc7a15ab71 | ||
|
|
530021aec6 | ||
|
|
1bedb4e59d | ||
|
|
7546e7751c | ||
|
|
075b319fb3 | ||
|
|
b82a42c855 | ||
|
|
e90be36cd5 | ||
|
|
d29bc23bb7 | ||
|
|
ee1389d736 | ||
|
|
6f42636514 | ||
|
|
28197e6504 | ||
|
|
2f721ec0d5 | ||
|
|
f9b412228e | ||
|
|
2bfd9fcffe | ||
|
|
7639655883 | ||
|
|
6044663788 | ||
|
|
f2318a8fef | ||
|
|
77f7e9fc91 | ||
|
|
e56eb14c52 |
@@ -3,7 +3,7 @@
|
||||
[branches]
|
||||
main = "main"
|
||||
perennials = []
|
||||
perennial-regex = ""
|
||||
perennial-regex = "^release-"
|
||||
|
||||
[create]
|
||||
new-branch-type = "feature"
|
||||
|
||||
@@ -2,7 +2,7 @@ name: Check License Dependencies
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
branches: [main, "release-*"]
|
||||
paths:
|
||||
- "go.mod"
|
||||
- "go.sum"
|
||||
|
||||
1
.github/workflows/frontend-ui.yml
vendored
1
.github/workflows/frontend-ui.yml
vendored
@@ -10,6 +10,7 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- "release-*"
|
||||
paths:
|
||||
- "client/ui/frontend/**"
|
||||
- "client/ui/i18n/**"
|
||||
|
||||
1
.github/workflows/golang-test-darwin.yml
vendored
1
.github/workflows/golang-test-darwin.yml
vendored
@@ -4,6 +4,7 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- "release-*"
|
||||
pull_request:
|
||||
|
||||
concurrency:
|
||||
|
||||
1
.github/workflows/golang-test-freebsd.yml
vendored
1
.github/workflows/golang-test-freebsd.yml
vendored
@@ -4,6 +4,7 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- "release-*"
|
||||
pull_request:
|
||||
|
||||
concurrency:
|
||||
|
||||
1
.github/workflows/golang-test-linux.yml
vendored
1
.github/workflows/golang-test-linux.yml
vendored
@@ -4,6 +4,7 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- "release-*"
|
||||
pull_request:
|
||||
|
||||
concurrency:
|
||||
|
||||
1
.github/workflows/golang-test-windows.yml
vendored
1
.github/workflows/golang-test-windows.yml
vendored
@@ -4,6 +4,7 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- "release-*"
|
||||
pull_request:
|
||||
|
||||
env:
|
||||
|
||||
1
.github/workflows/install-script-test.yml
vendored
1
.github/workflows/install-script-test.yml
vendored
@@ -4,6 +4,7 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- "release-*"
|
||||
pull_request:
|
||||
paths:
|
||||
- "release_files/install.sh"
|
||||
|
||||
@@ -4,6 +4,7 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- "release-*"
|
||||
pull_request:
|
||||
|
||||
concurrency:
|
||||
|
||||
2
.github/workflows/pr-title-check.yml
vendored
2
.github/workflows/pr-title-check.yml
vendored
@@ -16,6 +16,8 @@ jobs:
|
||||
const allowedTags = [
|
||||
'management',
|
||||
'client',
|
||||
'android',
|
||||
'ios',
|
||||
'signal',
|
||||
'proxy',
|
||||
'relay',
|
||||
|
||||
148
.github/workflows/release.yml
vendored
148
.github/workflows/release.yml
vendored
@@ -6,6 +6,7 @@ on:
|
||||
- "v*"
|
||||
branches:
|
||||
- main
|
||||
- "release-*"
|
||||
pull_request:
|
||||
|
||||
env:
|
||||
@@ -254,15 +255,23 @@ jobs:
|
||||
id: tag_and_push_images
|
||||
if: |
|
||||
(github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository) ||
|
||||
(github.event_name == 'push' && github.ref == 'refs/heads/main')
|
||||
(github.event_name == 'push' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/heads/release-')))
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
# $GITHUB_REF / $GITHUB_EVENT_NAME are read from the runner
|
||||
# environment rather than substituted into this script with the
|
||||
# workflow expression syntax: branch names may legally contain
|
||||
# $(…), and interpolating github.ref would execute it.
|
||||
resolve_tags() {
|
||||
if [[ "${{ github.event_name }}" == "pull_request" ]]; then
|
||||
if [[ "$GITHUB_EVENT_NAME" == "pull_request" ]]; then
|
||||
echo "pr-${{ github.event.pull_request.number }}"
|
||||
else
|
||||
elif [[ "$GITHUB_REF" == "refs/heads/main" ]]; then
|
||||
echo "main sha-$(git rev-parse --short HEAD)"
|
||||
else
|
||||
# Release branches get an immutable sha-* tag only — the floating
|
||||
# "main" tag must never move from a release branch.
|
||||
echo "sha-$(git rev-parse --short HEAD)"
|
||||
fi
|
||||
}
|
||||
|
||||
@@ -475,6 +484,132 @@ jobs:
|
||||
path: dist/
|
||||
retention-days: 3
|
||||
|
||||
release_ui_gtk3:
|
||||
# Legacy GTK3/WebKit2GTK 4.1 UI build for distros without WebKitGTK 6.0
|
||||
# (Ubuntu 22.04, Debian 12, RHEL 9, Fedora <=39). Runs on ubuntu-22.04 so
|
||||
# the binary links against the oldest supported glibc.
|
||||
runs-on: ubuntu-22.04
|
||||
outputs:
|
||||
release_ui_gtk3_artifact_url: ${{ steps.upload_release_ui_gtk3.outputs.artifact-url }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
fetch-depth: 0 # It is required for GoReleaser to work properly
|
||||
persist-credentials: false
|
||||
|
||||
- name: Parse semver string
|
||||
id: semver_parser
|
||||
uses: netbirdio/shared-actions/actions/parse-semver@be5df6047383da2236e02243cceb857d8567c27e # v0.0.2
|
||||
|
||||
- name: Set snapshot flag
|
||||
if: ${{ !startsWith(github.ref, 'refs/tags/v') }}
|
||||
run: |
|
||||
echo "flags=--snapshot" >> $GITHUB_ENV
|
||||
|
||||
- name: Set build vars
|
||||
if: ${{ startsWith(github.ref, 'refs/tags/v') }}
|
||||
run: |
|
||||
if [[ "x-${{ steps.semver_parser.outputs.prerelease }}" == "x-" && "x-${{ github.repository }}" == "x-netbirdio/netbird" ]]; then
|
||||
echo "x-${{ github.repository }}"
|
||||
echo "x-${{ steps.semver_parser.outputs.prerelease }}"
|
||||
echo "SKIP_PUBLISH=false" >> $GITHUB_ENV
|
||||
else
|
||||
echo "x-${{ github.repository }}"
|
||||
echo "x-${{ steps.semver_parser.outputs.prerelease }}"
|
||||
fi
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
|
||||
with:
|
||||
go-version-file: "go.mod"
|
||||
cache: false
|
||||
- name: Cache Go modules
|
||||
# Restore-only from the release_ui cache written by trusted runs; the
|
||||
# module cache is identical (same go.sum) and stale build-cache
|
||||
# entries just miss.
|
||||
uses: actions/cache/restore@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
|
||||
with:
|
||||
path: |
|
||||
~/go/pkg/mod
|
||||
~/.cache/go-build
|
||||
key: ${{ runner.os }}-ui-go-releaser-${{ hashFiles('**/go.sum') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-ui-go-releaser-
|
||||
|
||||
- name: Install modules
|
||||
run: go mod tidy
|
||||
|
||||
- name: check git status
|
||||
run: git --no-pager diff --exit-code
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
node-version: '22'
|
||||
|
||||
- name: Set up pnpm
|
||||
uses: pnpm/action-setup@a3252b78c470c02df07e9d59298aecedc3ccdd6d # v3.0.0
|
||||
with:
|
||||
version: 11
|
||||
|
||||
- name: Install dependencies
|
||||
run: sudo apt update && sudo apt install -y -q libgtk-3-dev libwebkit2gtk-4.1-dev
|
||||
|
||||
- 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 wails3 CLI
|
||||
# Version derived from go.mod so the binding generator always matches
|
||||
# the wails runtime the binary links against.
|
||||
# -tags gtk3: the CLI links the wails runtime's cgo packages, and the
|
||||
# default tags request gtk4/webkitgtk-6.0 pkg-config entries that do
|
||||
# not exist on ubuntu-22.04.
|
||||
run: |
|
||||
WAILS_VERSION=$(go list -m -f '{{.Version}}' github.com/wailsapp/wails/v3)
|
||||
go install -tags gtk3 github.com/wailsapp/wails/v3/cmd/wails3@$WAILS_VERSION
|
||||
|
||||
- name: Run GoReleaser
|
||||
uses: goreleaser/goreleaser-action@5daf1e915a5f0af01ddbcd89a43b8061ff4f1a89 # v7.2.2
|
||||
with:
|
||||
version: ${{ env.GORELEASER_VER }}
|
||||
args: release --config .goreleaser_ui_gtk3.yaml --clean ${{ env.flags }}
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.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_GTK3_PASSPHRASE: ${{ secrets.GPG_RPM_PASSPHRASE }}
|
||||
SKIP_PUBLISH: ${{ env.SKIP_PUBLISH }}
|
||||
- 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
|
||||
id: upload_release_ui_gtk3
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a #v7.0.1
|
||||
with:
|
||||
name: release-ui-gtk3
|
||||
path: dist/
|
||||
retention-days: 3
|
||||
|
||||
release_ui_darwin:
|
||||
runs-on: macos-latest
|
||||
outputs:
|
||||
@@ -688,7 +823,7 @@ jobs:
|
||||
comment_release_artifacts:
|
||||
name: Comment release artifacts
|
||||
runs-on: ubuntu-latest
|
||||
needs: [release, release_ui, release_ui_darwin]
|
||||
needs: [release, release_ui, release_ui_gtk3, release_ui_darwin]
|
||||
if: ${{ always() && github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository }}
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -700,12 +835,14 @@ jobs:
|
||||
env:
|
||||
RELEASE_RESULT: ${{ needs.release.result }}
|
||||
RELEASE_UI_RESULT: ${{ needs.release_ui.result }}
|
||||
RELEASE_UI_GTK3_RESULT: ${{ needs.release_ui_gtk3.result }}
|
||||
RELEASE_UI_DARWIN_RESULT: ${{ needs.release_ui_darwin.result }}
|
||||
RELEASE_ARTIFACT_URL: ${{ needs.release.outputs.release_artifact_url }}
|
||||
LINUX_PACKAGES_ARTIFACT_URL: ${{ needs.release.outputs.linux_packages_artifact_url }}
|
||||
WINDOWS_PACKAGES_ARTIFACT_URL: ${{ needs.release.outputs.windows_packages_artifact_url }}
|
||||
MACOS_PACKAGES_ARTIFACT_URL: ${{ needs.release.outputs.macos_packages_artifact_url }}
|
||||
RELEASE_UI_ARTIFACT_URL: ${{ needs.release_ui.outputs.release_ui_artifact_url }}
|
||||
RELEASE_UI_GTK3_ARTIFACT_URL: ${{ needs.release_ui_gtk3.outputs.release_ui_gtk3_artifact_url }}
|
||||
RELEASE_UI_DARWIN_ARTIFACT_URL: ${{ needs.release_ui_darwin.outputs.release_ui_darwin_artifact_url }}
|
||||
GHCR_IMAGES_MARKDOWN: ${{ needs.release.outputs.ghcr_images }}
|
||||
with:
|
||||
@@ -728,6 +865,7 @@ jobs:
|
||||
['Windows packages', process.env.WINDOWS_PACKAGES_ARTIFACT_URL, process.env.RELEASE_RESULT],
|
||||
['macOS packages', process.env.MACOS_PACKAGES_ARTIFACT_URL, process.env.RELEASE_RESULT],
|
||||
['UI artifacts', process.env.RELEASE_UI_ARTIFACT_URL, process.env.RELEASE_UI_RESULT],
|
||||
['UI GTK3 artifacts', process.env.RELEASE_UI_GTK3_ARTIFACT_URL, process.env.RELEASE_UI_GTK3_RESULT],
|
||||
['UI macOS artifacts', process.env.RELEASE_UI_DARWIN_ARTIFACT_URL, process.env.RELEASE_UI_DARWIN_RESULT],
|
||||
];
|
||||
|
||||
@@ -784,7 +922,7 @@ jobs:
|
||||
|
||||
trigger_signer:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [release, release_ui, release_ui_darwin, test_windows_installer]
|
||||
needs: [release, release_ui, release_ui_gtk3, release_ui_darwin, test_windows_installer]
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
steps:
|
||||
- name: Trigger binaries sign pipelines
|
||||
|
||||
16
.github/workflows/sync-tag.yml
vendored
16
.github/workflows/sync-tag.yml
vendored
@@ -9,21 +9,9 @@ concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}-${{ github.head_ref || github.actor_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
# Receiving workflows (cloud sync-tag, mobile bump-netbird) expect the short
|
||||
# tag form (e.g. v0.30.0), not refs/tags/v0.30.0 — github.ref_name, not github.ref.
|
||||
# The receiving bump-netbird workflows expect the short tag form
|
||||
# (e.g. v0.30.0), not refs/tags/v0.30.0 — github.ref_name, not github.ref.
|
||||
jobs:
|
||||
trigger_sync_tag:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Trigger release tag sync
|
||||
uses: benc-uk/workflow-dispatch@31e2b3319479a63f0ab15bf800eff9e913504e26 # v1.3.2
|
||||
with:
|
||||
workflow: sync-tag.yml
|
||||
ref: main
|
||||
repo: ${{ secrets.UPSTREAM_REPO }}
|
||||
token: ${{ secrets.NC_GITHUB_TOKEN }}
|
||||
inputs: '{ "tag": "${{ github.ref_name }}" }'
|
||||
|
||||
trigger_android_bump:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event.created && !github.event.deleted && startsWith(github.ref, 'refs/tags/v') && !contains(github.ref_name, '-')
|
||||
|
||||
10
.github/workflows/test-infrastructure-files.yml
vendored
10
.github/workflows/test-infrastructure-files.yml
vendored
@@ -4,6 +4,7 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- "release-*"
|
||||
pull_request:
|
||||
paths:
|
||||
- "infrastructure_files/**"
|
||||
@@ -257,6 +258,15 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Verify fresh-install session cookie key hardening
|
||||
run: |
|
||||
grep -Fxq ' SESSION_COOKIE_ENCRYPTION_KEY=$(openssl rand -base64 32)' infrastructure_files/getting-started.sh
|
||||
grep -Fxq ' sessionCookieEncryptionKey: "$SESSION_COOKIE_ENCRYPTION_KEY"' infrastructure_files/getting-started.sh
|
||||
grep -Fxq ' install -m 600 /dev/null config.yaml' infrastructure_files/getting-started.sh
|
||||
grep -Fxq ' openssl rand -base64 32' infrastructure_files/getting-started-enterprise.sh
|
||||
grep -Fxq ' NETBIRD_SESSION_COOKIE_ENCRYPTION_KEY=$(rand_b64_key)' infrastructure_files/getting-started-enterprise.sh
|
||||
grep -Fxq ' sessionCookieEncryptionKey: "${NETBIRD_SESSION_COOKIE_ENCRYPTION_KEY}"' infrastructure_files/getting-started-enterprise.sh
|
||||
|
||||
- name: Verify Dex retirement notice
|
||||
run: |
|
||||
if infrastructure_files/getting-started-with-dex.sh >stdout.txt 2>stderr.txt; then
|
||||
|
||||
42
.github/workflows/ui-translations.yml
vendored
Normal file
42
.github/workflows/ui-translations.yml
vendored
Normal file
@@ -0,0 +1,42 @@
|
||||
name: UI Translations
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- "client/ui/i18n/locales/**"
|
||||
- "client/ui/i18n/check-translations.mjs"
|
||||
- ".github/workflows/ui-translations.yml"
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- "client/ui/i18n/locales/**"
|
||||
- "client/ui/i18n/check-translations.mjs"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}-${{ github.head_ref || github.actor_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
check-translations:
|
||||
name: Check translation key parity
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
|
||||
# English (en) is the source of truth for translation keys; every other
|
||||
# locale declared in _index.json must carry the exact same key set.
|
||||
- name: Check translation key parity
|
||||
run: node client/ui/i18n/check-translations.mjs
|
||||
1
.github/workflows/wasm-build-validation.yml
vendored
1
.github/workflows/wasm-build-validation.yml
vendored
@@ -4,6 +4,7 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- "release-*"
|
||||
pull_request:
|
||||
|
||||
concurrency:
|
||||
|
||||
@@ -468,6 +468,13 @@ checksum:
|
||||
- glob: ./infrastructure_files/migrate-to-enterprise.sh
|
||||
|
||||
release:
|
||||
# The signing pipeline (netbirdio/sign-pipelines, dispatched by
|
||||
# trigger_signer) marks the release latest once the Windows and macOS
|
||||
# artifacts are signed. Without this override goreleaser marks it latest
|
||||
# at publish time, while those artifacts are still unsigned.
|
||||
make_latest: false
|
||||
# Mark x.y.z-rc.* and other prerelease tags as prereleases on GitHub.
|
||||
prerelease: auto
|
||||
extra_files:
|
||||
- glob: ./infrastructure_files/getting-started-with-zitadel.sh
|
||||
- glob: ./release_files/install.sh
|
||||
|
||||
@@ -93,7 +93,10 @@ nfpms:
|
||||
- src: client/ui/build/appicon.png
|
||||
dst: /usr/share/pixmaps/netbird.png
|
||||
dependencies:
|
||||
- netbird
|
||||
- netbird (>= 0.75.0)
|
||||
- libgtk-4-1 (>= 4.14)
|
||||
- libwebkitgtk-6.0-4
|
||||
- xdg-utils
|
||||
|
||||
- maintainer: Netbird <dev@netbird.io>
|
||||
description: Netbird client UI.
|
||||
@@ -114,7 +117,10 @@ nfpms:
|
||||
- src: client/ui/build/appicon.png
|
||||
dst: /usr/share/pixmaps/netbird.png
|
||||
dependencies:
|
||||
- netbird
|
||||
- netbird >= 0.75.0
|
||||
- (gtk4 >= 4.14 or libgtk-4-1 >= 4.14)
|
||||
- (webkitgtk6.0 or libwebkitgtk-6_0-4)
|
||||
- xdg-utils
|
||||
|
||||
rpm:
|
||||
signature:
|
||||
@@ -138,3 +144,11 @@ uploads:
|
||||
target: https://pkgs.wiretrustee.com/yum/{{ .Arch }}{{ if .Arm }}{{ .Arm }}{{ end }}
|
||||
username: dev@wiretrustee.com
|
||||
method: PUT
|
||||
|
||||
release:
|
||||
# Uploads into the release created by the main .goreleaser.yaml run.
|
||||
# make_latest stays false everywhere: the signing pipeline
|
||||
# (netbirdio/sign-pipelines) marks the release latest after the Windows
|
||||
# and macOS artifacts are signed.
|
||||
make_latest: false
|
||||
prerelease: auto
|
||||
|
||||
@@ -43,3 +43,11 @@ checksum:
|
||||
name_template: "{{ .ProjectName }}_darwin_checksums.txt"
|
||||
changelog:
|
||||
disable: true
|
||||
|
||||
release:
|
||||
# Uploads into the release created by the main .goreleaser.yaml run.
|
||||
# make_latest stays false everywhere: the signing pipeline
|
||||
# (netbirdio/sign-pipelines) marks the release latest after the Windows
|
||||
# and macOS artifacts are signed.
|
||||
make_latest: false
|
||||
prerelease: auto
|
||||
|
||||
144
.goreleaser_ui_gtk3.yaml
Normal file
144
.goreleaser_ui_gtk3.yaml
Normal file
@@ -0,0 +1,144 @@
|
||||
version: 2
|
||||
env:
|
||||
- SKIP_PUBLISH={{ if index .Env "SKIP_PUBLISH" }}{{ .Env.SKIP_PUBLISH }}{{ else }}true{{ end }}
|
||||
project_name: netbird-ui
|
||||
|
||||
before:
|
||||
hooks:
|
||||
# Bindings are gitignored; regenerate before the frontend build so
|
||||
# the @wailsio/runtime Vite plugin can resolve them (vite refuses to
|
||||
# build without them).
|
||||
# -f '-tags gtk3': the generator type-checks client/ui, whose cgo imports
|
||||
# would otherwise resolve gtk4/webkitgtk-6.0 pkg-config entries that do
|
||||
# not exist on ubuntu-22.04.
|
||||
- sh -c 'cd client/ui && wails3 generate bindings -clean=true -ts -f "-tags gtk3"'
|
||||
- sh -c 'cd client/ui/frontend && pnpm install --frozen-lockfile && pnpm build'
|
||||
|
||||
builds:
|
||||
# Legacy GTK3 / WebKit2GTK 4.1 build for distros without WebKitGTK 6.0
|
||||
# (Ubuntu 22.04, Debian 12, RHEL 9, Fedora <=39). The gtk3 tag flips the
|
||||
# Wails Linux backend to the GTK3 stack and swaps our GTK4-only XEmbed
|
||||
# tray host for the pure-Go stub (client/ui/xembed_host_gtk3_linux.go).
|
||||
# Must be built on the oldest supported glibc (ubuntu-22.04 runner).
|
||||
- id: netbird-ui-gtk3
|
||||
dir: client/ui
|
||||
binary: netbird-ui
|
||||
env:
|
||||
- CGO_ENABLED=1
|
||||
goos:
|
||||
- linux
|
||||
goarch:
|
||||
- amd64
|
||||
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 }}"
|
||||
tags:
|
||||
- production
|
||||
- gtk3
|
||||
|
||||
archives:
|
||||
- id: linux-gtk3-arch
|
||||
name_template: "{{ .ProjectName }}-linux-gtk3_{{ .Version }}_{{ .Os }}_{{ .Arch }}"
|
||||
builds:
|
||||
- netbird-ui-gtk3
|
||||
|
||||
nfpms:
|
||||
# Mutually-exclusive alternative to the GTK4 netbird-ui package -- both
|
||||
# ship the same /usr/bin/netbird-ui from the shared stable/yum repos, so
|
||||
# this one carries its own name and conflicts with the GTK4 package.
|
||||
- maintainer: Netbird <dev@netbird.io>
|
||||
description: Netbird client UI.
|
||||
homepage: https://netbird.io/
|
||||
license: BSD-3-Clause
|
||||
vendor: NetBird
|
||||
id: netbird_ui_deb_gtk3
|
||||
package_name: netbird-ui-gtk3
|
||||
file_name_template: "{{ .PackageName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}"
|
||||
builds:
|
||||
- netbird-ui-gtk3
|
||||
formats:
|
||||
- deb
|
||||
scripts:
|
||||
postinstall: "release_files/ui-post-install.sh"
|
||||
contents:
|
||||
- src: client/ui/build/linux/netbird.desktop
|
||||
dst: /usr/share/applications/org.wails.netbird.desktop
|
||||
- src: client/ui/build/appicon.png
|
||||
dst: /usr/share/pixmaps/netbird.png
|
||||
conflicts:
|
||||
- netbird-ui
|
||||
replaces:
|
||||
- netbird-ui
|
||||
dependencies:
|
||||
- netbird (>= 0.75.0)
|
||||
- libgtk-3-0
|
||||
- libwebkit2gtk-4.1-0
|
||||
- xdg-utils
|
||||
|
||||
- maintainer: Netbird <dev@netbird.io>
|
||||
description: Netbird client UI.
|
||||
homepage: https://netbird.io/
|
||||
license: BSD-3-Clause
|
||||
vendor: NetBird
|
||||
id: netbird_ui_rpm_gtk3
|
||||
package_name: netbird-ui-gtk3
|
||||
file_name_template: "{{ .PackageName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}"
|
||||
builds:
|
||||
- netbird-ui-gtk3
|
||||
formats:
|
||||
- rpm
|
||||
scripts:
|
||||
postinstall: "release_files/ui-post-install.sh"
|
||||
contents:
|
||||
- src: client/ui/build/linux/netbird.desktop
|
||||
dst: /usr/share/applications/org.wails.netbird.desktop
|
||||
- src: client/ui/build/appicon.png
|
||||
dst: /usr/share/pixmaps/netbird.png
|
||||
# No `replaces` here: nfpm maps it to rpm Obsoletes, which would make
|
||||
# dnf swap installed GTK4 netbird-ui packages for this one on upgrade.
|
||||
conflicts:
|
||||
- netbird-ui
|
||||
dependencies:
|
||||
- netbird >= 0.75.0
|
||||
- (gtk3 or libgtk-3-0)
|
||||
- (webkit2gtk4.1 or libwebkit2gtk-4_1-0)
|
||||
- xdg-utils
|
||||
|
||||
rpm:
|
||||
signature:
|
||||
key_file: '{{ if index .Env "GPG_RPM_KEY_FILE" }}{{ .Env.GPG_RPM_KEY_FILE }}{{ end }}'
|
||||
|
||||
# The GTK4 UI job shares project_name, so the default checksum file name would
|
||||
# collide with it on the shared GitHub release.
|
||||
checksum:
|
||||
name_template: "{{ .ProjectName }}_gtk3_checksums.txt"
|
||||
|
||||
changelog:
|
||||
disable: true
|
||||
|
||||
uploads:
|
||||
- name: debian
|
||||
skip: "{{ .Env.SKIP_PUBLISH }}"
|
||||
ids:
|
||||
- netbird_ui_deb_gtk3
|
||||
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
|
||||
method: PUT
|
||||
|
||||
- name: yum
|
||||
skip: "{{ .Env.SKIP_PUBLISH }}"
|
||||
ids:
|
||||
- netbird_ui_rpm_gtk3
|
||||
mode: archive
|
||||
target: https://pkgs.wiretrustee.com/yum/{{ .Arch }}{{ if .Arm }}{{ .Arm }}{{ end }}
|
||||
username: dev@wiretrustee.com
|
||||
method: PUT
|
||||
|
||||
release:
|
||||
# Uploads into the release created by the main .goreleaser.yaml run.
|
||||
# make_latest stays false everywhere: the signing pipeline
|
||||
# (netbirdio/sign-pipelines) marks the release latest after the Windows
|
||||
# and macOS artifacts are signed.
|
||||
make_latest: false
|
||||
prerelease: auto
|
||||
783
AGENTS.md
Normal file
783
AGENTS.md
Normal file
@@ -0,0 +1,783 @@
|
||||
# NetBird Agent Guidelines
|
||||
|
||||
**NetBird** is an open-source connectivity platform: a WireGuard®-based overlay
|
||||
network with a control plane. The **agent** (`client/`) runs on user machines as
|
||||
a privileged daemon and manages the WireGuard interface, routing, firewall, and
|
||||
DNS. **Management** (`management/`) is the control plane and REST/gRPC API,
|
||||
**Signal** (`signal/`) brokers peer handshakes, **Relay** (`relay/`) carries
|
||||
traffic when a direct tunnel is impossible, and **Proxy** (`proxy/`) is the
|
||||
identity-aware proxy behind Agent Network.
|
||||
|
||||
This file applies to the whole repository, and is the single source of truth for
|
||||
agent guidance here. `CLAUDE.md` is a one-line pointer to it — keep the guidance
|
||||
in this file, not duplicated there.
|
||||
|
||||
## Contents
|
||||
|
||||
- [STOP and ask the user before](#stop-and-ask-the-user-before)
|
||||
- [Quick reference](#quick-reference)
|
||||
- [Structure](#structure)
|
||||
- [Where to look](#where-to-look)
|
||||
- [Security](#security)
|
||||
- [Agent conventions](#agent-conventions)
|
||||
- [Repo-wide principles](#repo-wide-principles)
|
||||
- [Type safety](#type-safety)
|
||||
- [Concurrency and lifecycle](#concurrency-and-lifecycle)
|
||||
- [Error handling](#error-handling)
|
||||
- [Comments](#comments)
|
||||
- [Testing](#testing)
|
||||
- [Pitfalls](#pitfalls)
|
||||
- [Commits, PRs, releases](#commits-prs-releases)
|
||||
- [After you push: CI and review bots](#after-you-push-ci-and-review-bots)
|
||||
- [Discussion and support](#discussion-and-support)
|
||||
|
||||
## STOP and ask the user before
|
||||
|
||||
- **Opening a pull request for anything beyond a trivial fix, without an agreed
|
||||
ticket.** Ask the user directly: *"Is there a discussion or issue for this
|
||||
change?"* NetBird is discussion-first — community reports start in
|
||||
[Discussions](https://github.com/netbirdio/netbird/discussions), DevRel
|
||||
validates them, and only validated discussions become issues. A PR that
|
||||
changes behavior with no linked issue may be closed on arrival. If there is no
|
||||
ticket, offer to draft the discussion post **instead of** the PR, and wait for
|
||||
the user's call. Only typos, broken links, documentation corrections, and
|
||||
one-line fixes that already have an issue can skip this.
|
||||
- **Designing in any high-risk area** (see
|
||||
[CONTRIBUTING.md](CONTRIBUTING.md#high-risk-areas)): public API and OpenAPI
|
||||
schema, gRPC protos, behavior existing deployments would notice after an
|
||||
upgrade, peer connectivity (ICE, NAT traversal, relay selection, WireGuard® or
|
||||
Rosenpass key handling), client system integration (routing, firewall, DNS,
|
||||
interface), authentication and authorization, CLI or service flags, config
|
||||
file format, daemon IPC, store schema and migrations, or a new feature. The
|
||||
design gets agreed in the ticket before code is written.
|
||||
- **Writing a store migration or changing a persisted model.** Migrations are
|
||||
one-way in the field and both the GORM and pgx paths may need the change.
|
||||
- **Hand-editing generated code.** `*.pb.go`, `*.gen.go`, and mocks are outputs.
|
||||
Edit the source (`.proto`, `openapi.yml`) and rerun the matching
|
||||
`generate.sh`.
|
||||
- **Adding, removing, or bumping a dependency**, and never vendor a fork.
|
||||
- **Weakening a security control** — authentication, authorization, certificate
|
||||
verification, privilege dropping, or peer identity checks — even when it is
|
||||
the fastest way to make a test pass.
|
||||
- **Force-pushing to `main`**, force-pushing any branch that is already under
|
||||
review, amending pushed commits, or bypassing hooks with `--no-verify`.
|
||||
|
||||
## Quick reference
|
||||
|
||||
```bash
|
||||
# Build
|
||||
go build ./...
|
||||
cd client && CGO_ENABLED=0 go build . # agent
|
||||
cd management && go build . # management service
|
||||
cd signal && go build . # signal service
|
||||
|
||||
# Verify (run before every push)
|
||||
go fmt ./...
|
||||
make lint # golangci-lint on files changed vs origin/main (also the pre-push hook)
|
||||
make lint-all # full-repository lint, matches CI
|
||||
make test-unit # host-safe unit tests, -tags devcert, no sudo
|
||||
make test-privileged # privileged-tagged suite in a Docker container with NET_ADMIN
|
||||
make setup-hooks # wire make lint into .githooks/pre-push
|
||||
|
||||
# Narrow runs
|
||||
go test ./client/internal/dns/...
|
||||
go test -race -run TestPeerConn ./client/internal/peer/...
|
||||
PRIV_RUN=TestNftablesManager PRIV_PKGS=./client/firewall/nftables/... make test-privileged
|
||||
|
||||
# Code generation (never hand-edit the output)
|
||||
./shared/management/http/api/generate.sh # REST types from openapi.yml
|
||||
./shared/management/proto/generate.sh
|
||||
./shared/signal/proto/generate.sh
|
||||
./client/proto/generate.sh
|
||||
./flow/proto/generate.sh
|
||||
|
||||
# Run locally (lab only, never on a machine you rely on)
|
||||
sudo ./client/netbird up --log-level debug --log-file console
|
||||
sudo ./client/netbird down # teardown: restores routing, firewall, DNS
|
||||
./signal/signal run --log-level debug --log-file console
|
||||
./management/management management --log-level debug --log-file console --config ./management.json
|
||||
```
|
||||
|
||||
`netbird up` needs root and rewrites the host's routing table, firewall rules,
|
||||
DNS configuration, and WireGuard® interface. Run it only in a disposable test
|
||||
environment (a VM, container, or throwaway host) that you can rebuild, never on
|
||||
a workstation or server whose connectivity matters. Run `sudo netbird down`
|
||||
before you stop working, before rebuilding the binary, and on every failure
|
||||
path, so the host's networking state is restored instead of left half-applied.
|
||||
See [Pitfalls](#pitfalls) for why cleanup on every exit path matters.
|
||||
|
||||
## Structure
|
||||
|
||||
```text
|
||||
netbird/
|
||||
├── client/ NetBird agent
|
||||
│ ├── cmd/ agent CLI
|
||||
│ ├── internal/ agent business logic (engine, peer, dns, routemanager, ...)
|
||||
│ ├── server/ daemon for background execution
|
||||
│ ├── proto/ daemon gRPC protos
|
||||
│ ├── iface/ WireGuard® interface management
|
||||
│ ├── firewall/ nftables, iptables, pf, WFP, userspace backends
|
||||
│ ├── ssh/ built-in SSH server and client
|
||||
│ ├── ui/ desktop UI (Wails v3 + React)
|
||||
│ ├── android/, ios/ mobile bindings
|
||||
│ ├── wasm/ WebAssembly build
|
||||
│ └── mdm/, system/ MDM policy, host information
|
||||
├── management/ control plane
|
||||
│ └── server/ account, peer, groups, networks, posture, permissions,
|
||||
│ settings, store, http (REST), idp, integrations, migration
|
||||
├── signal/ handshake broker (peer/, server/)
|
||||
├── relay/ relay service (protocol/, server/, healthcheck/)
|
||||
├── proxy/ identity-aware proxy (llm/, acme/, accesslog/, middleware/, tcp/, udp/)
|
||||
├── agent-network/ Agent Network overview
|
||||
├── shared/ imported by both agent and services
|
||||
│ ├── management/ proto/, client/, http/api (OpenAPI + generated types)
|
||||
│ ├── signal/ proto/, client/
|
||||
│ └── relay/, auth/, sshauth/, metrics/
|
||||
├── e2e/ end-to-end suites and harness
|
||||
├── encryption/, dns/, route/, stun/, sharedsock/, util/, flow/
|
||||
├── infrastructure_files/ docker compose and getting-started templates
|
||||
└── release_files/ files packaged into releases
|
||||
```
|
||||
|
||||
## Where to look
|
||||
|
||||
| Task | Location |
|
||||
| --------------------------- | ------------------------------------------------------------ |
|
||||
| REST API / OpenAPI | `shared/management/http/api/` + `management/server/http/` |
|
||||
| Management gRPC protocol | `shared/management/proto/` |
|
||||
| Signal protocol | `shared/signal/proto/` |
|
||||
| Daemon IPC protocol | `client/proto/` |
|
||||
| Peer connection and NAT | `client/internal/peer/` |
|
||||
| Network map handling | `client/internal/engine.go`, `shared/management/networkmap/` |
|
||||
| Routing | `client/internal/routemanager/`, `route/` |
|
||||
| Firewall backends | `client/firewall/` |
|
||||
| DNS | `client/internal/dns/`, `dns/` |
|
||||
| WireGuard® interface | `client/iface/` |
|
||||
| Persistence and migrations | `management/server/store/`, `management/server/migration/` |
|
||||
| IdP integrations | `management/server/idp/` |
|
||||
| Permissions model | `management/server/permissions/` |
|
||||
| LLM routing / Agent Network | `proxy/internal/llm/`, `agent-network/` |
|
||||
| End-to-end tests | `e2e/` |
|
||||
|
||||
## Security
|
||||
|
||||
### Never fail open
|
||||
|
||||
When a security check — access control, an IP restriction, an auth decision —
|
||||
hits an error such as an unparseable value, an unavailable lookup, or a state it
|
||||
does not recognize, it must **deny**. Never skip the check or allow the request
|
||||
through because the check itself failed, and make the `default` and unknown cases
|
||||
of a security-related `switch` deny rather than fall through.
|
||||
|
||||
### Daemon RPC input is untrusted
|
||||
|
||||
The agent runs as root (LocalSystem on Windows), so a daemon RPC crosses a
|
||||
privilege boundary: treat every field as untrusted input rather than as something
|
||||
the UI or CLI validated on the way in.
|
||||
|
||||
When you add or change an RPC, ask what the handler does with caller input while
|
||||
running as root. If the answer touches a filesystem path, a URL or host, or a
|
||||
privileged state change, it needs a gate **in the handler** — a check in the client
|
||||
that normally calls it is not a check at all.
|
||||
|
||||
- **A caller-supplied path the daemon opens.** Never `os.Open` it as root.
|
||||
Constrain it, then open it *as the caller* with `ipcauth.OpenOwnedFile`, which
|
||||
opens `O_NOFOLLOW`, requires a regular file, and refuses a file the caller does
|
||||
not own — so a symlink or hardlink aimed at a root-only file is rejected.
|
||||
- **A caller-supplied URL or host the daemon fetches.** Restrict the scheme and
|
||||
allow only known hosts for unprivileged callers. Prefer a lexical host
|
||||
allowlist plus TLS verification over "resolve the host, then reject private
|
||||
IPs": the resolve-then-trust pattern has a DNS-rebinding race (public IP at
|
||||
check time, attacker IP at connect time), while a name allowlist has no IP
|
||||
check to race. Never accept `http://` where `https://` is expected.
|
||||
- **A privileged state change** (SSH root login, management URL, deregistration)
|
||||
gates on the caller identity from `ipcauth.CallerIdentity(ctx)`.
|
||||
|
||||
Caller identity comes from the kernel — `SO_PEERCRED`, `LOCAL_PEERCRED`, or the
|
||||
named-pipe client token — and never from an RPC field. When
|
||||
`ipcauth.CallerIdentity` reports that it could not determine an identity, **deny**;
|
||||
do not fall back to treating the caller as the transport peer.
|
||||
|
||||
## Agent conventions
|
||||
|
||||
### Three networking modes
|
||||
|
||||
Where packets actually flow depends on the mode the agent is running in. The
|
||||
three are not interchangeable, so establish which one a change applies to — and
|
||||
what it should do in the other two — before you write it.
|
||||
|
||||
- **kernel mode** (Linux only): in-kernel WireGuard®. The kernel handles both
|
||||
peer-to-peer and routed traffic, and ACLs are iptables or nftables rules. The
|
||||
client programs kernel facilities but never sees the traffic itself.
|
||||
- **userspace mode** (wireguard-go with a TUN): wireguard-go runs in-process. The
|
||||
kernel handles peer-to-peer traffic once it leaves the TUN, while routed traffic
|
||||
— exit nodes and network routes — goes through the userspace forwarder, which
|
||||
terminates the connection and re-establishes it over OS sockets. Used on
|
||||
platforms without kernel WireGuard® or when the user opts out.
|
||||
- **netstack mode**: wireguard-go in-process with no TUN and no kernel
|
||||
networking. The forwarder does all routing by stitching userspace sockets, and
|
||||
listeners such as the embedded SSH and DNS servers bind on a gVisor netstack.
|
||||
Used where the process cannot create a TUN device, such as the embedded client
|
||||
(`client/embed/`) and the WASM build.
|
||||
|
||||
### The overlay interface is not "WireGuard"
|
||||
|
||||
Do not put "WireGuard" in identifiers or comments unless the code is genuinely
|
||||
coupled to WireGuard® specifically — a wireguard-go call, a handshake field, a
|
||||
kernel WireGuard® netlink attribute. For the interface, the host, peers, or
|
||||
traffic in general, say "the NetBird interface", "the interface", or "the overlay".
|
||||
Most firewall, routing, and DNS code is transport-agnostic, so a WireGuard®
|
||||
reference there is simply inaccurate and rots as the transports change.
|
||||
|
||||
### IPv6 is a soft feature
|
||||
|
||||
The IPv6 overlay is opt-in dual-stack, and capability can change at runtime. Treat
|
||||
it as soft rather than a requirement:
|
||||
|
||||
- Gate local v6 paths on the interface accessor (`wgIface.Address().HasIPv6()`),
|
||||
not on raw state fields, and skip the v6 path when the host has no v6 rather
|
||||
than returning an error.
|
||||
- Treat an empty or unparseable peer v6 address as "no v6 for that peer" and skip
|
||||
it, keeping the v4 path working.
|
||||
- Never let a missing v6 break v4. Fail-closed is for security checks; a
|
||||
capability mismatch skips the v6 work and carries on.
|
||||
|
||||
### Environment variables
|
||||
|
||||
Name the variable in a constant and parse booleans with `strconv.ParseBool` rather
|
||||
than comparing strings inline, so an unexpected value is logged instead of
|
||||
silently meaning false:
|
||||
|
||||
```go
|
||||
const EnvDisableFeature = "NB_DISABLE_FEATURE"
|
||||
|
||||
func isDisabledByEnv() bool {
|
||||
val := os.Getenv(EnvDisableFeature)
|
||||
if val == "" {
|
||||
return false
|
||||
}
|
||||
disabled, err := strconv.ParseBool(val)
|
||||
if err != nil {
|
||||
log.Warnf("failed to parse %s: %v", EnvDisableFeature, err)
|
||||
return false
|
||||
}
|
||||
return disabled
|
||||
}
|
||||
```
|
||||
|
||||
### Validating against protocol specs
|
||||
|
||||
When a change depends on what a protocol actually mandates, read the specification
|
||||
text from the [IETF datatracker](https://datatracker.ietf.org/) rather than a
|
||||
summary, and check that you have the current RFC — the widely cited one for a
|
||||
protocol is often superseded. Cite the section, not just the document, so a
|
||||
reviewer can jump straight to the rule.
|
||||
|
||||
## Repo-wide principles
|
||||
|
||||
1. **Run `go fmt` on every modified Go file.** Formatting is not optional.
|
||||
2. **Zero unaddressed linter warnings.** Fix what `golangci-lint` reports on code
|
||||
you touch, and delete imports, helpers, and parameters your refactor orphaned.
|
||||
Exception: unused parameters in shared code may be consumed by builds outside
|
||||
this repository — do not remove them, ask instead.
|
||||
3. **Function comments are mandatory for exported functions**, written as full
|
||||
sentences with a period, starting with the identifier name.
|
||||
4. **Prefer private functions and constants.** Export only what a caller outside
|
||||
the package genuinely needs.
|
||||
5. **Early returns and guard clauses.** Handle errors and edge cases first
|
||||
instead of nesting `if`/`else` chains.
|
||||
6. **Split complex functions.** If a function trips a complexity warning, break
|
||||
it into named helpers rather than silencing the warning.
|
||||
7. **Avoid LLM-slop tells:** em dashes, hedging narration, restating the diff in
|
||||
prose, trailing summaries. Defaults, not absolute bans. Applies to code,
|
||||
comments, commit messages, and PR descriptions alike.
|
||||
8. **Concurrency: do a two-pass race analysis after every change** that touches
|
||||
shared state, including reads of existing maps and slices. Guard them with a
|
||||
mutex (or an atomic or channel where that fits better), keep critical
|
||||
sections short, and run `go test -race` on the touched packages. See
|
||||
[Concurrency and lifecycle](#concurrency-and-lifecycle) for the failure modes
|
||||
to check for.
|
||||
9. **Cross-platform builds must keep working.** The agent targets Linux, macOS,
|
||||
Windows, FreeBSD, Android, and iOS. When you add a platform-specific file,
|
||||
add the counterpart or a build-tagged fallback for the others.
|
||||
10. **Never hand-edit generated files.** Change the source and regenerate.
|
||||
11. **Never log secrets** — private keys, setup keys, tokens, PAT values — and
|
||||
keep peer IPs and hostnames out of logs above debug level.
|
||||
|
||||
## Type safety
|
||||
|
||||
**No bare primitives for domain concepts.** A `string` parameter for an account
|
||||
ID next to a `string` parameter for a peer ID is two bugs waiting to happen,
|
||||
because the compiler cannot catch the swap. Declare the type once and use it
|
||||
throughout, converting only at the boundaries where data enters or leaves —
|
||||
protobuf, gRPC, HTTP, an external library.
|
||||
|
||||
```go
|
||||
type ServiceID string
|
||||
type AccountID string
|
||||
|
||||
// Internal: typed all the way through
|
||||
func (r *Router) RemoveRoute(host SNIHost, svcID ServiceID) { ... }
|
||||
|
||||
// Proto boundary: convert once, on the way in and on the way out
|
||||
svcID := ServiceID(mapping.GetId())
|
||||
req.ServiceId = string(svcID)
|
||||
```
|
||||
|
||||
- **IP addresses are `netip.Addr`**, not `string` and not `net.IP`. Parse at the
|
||||
boundary and pass the typed value inward.
|
||||
- **Always `Unmap()`** after parsing an address, after converting from `net.IP`,
|
||||
and after extracting one from `RemoteAddr()`. This normalizes a v4-mapped v6
|
||||
address (`::ffff:10.1.2.3`) to plain v4 so IPv4 rules match it. A stored or
|
||||
compared mapped address silently fails to match those rules.
|
||||
- **Ports are `uint16`** internally; use `int` only where a library forces it and
|
||||
convert immediately.
|
||||
- **Enums are a typed string with constants**, so the valid set is discoverable
|
||||
and a typo fails to compile.
|
||||
- **Map keys follow the same rule**, and must be a real type (`type ServiceID
|
||||
string`) rather than an alias (`type serviceID = string`) — an alias silently
|
||||
accepts bare strings.
|
||||
|
||||
## Concurrency and lifecycle
|
||||
|
||||
Beyond the mutex hygiene in the principles above, check for these failure
|
||||
modes.
|
||||
|
||||
- **Never read a struct field inside a goroutine** when another goroutine may nil
|
||||
or reassign it. Pass the value as a parameter, or capture it into a local before
|
||||
launching. This matters most when `Stop()` nils a field without waiting for the
|
||||
goroutine to finish.
|
||||
|
||||
```go
|
||||
go func(ifaceName string) { // good: passed in, cannot be nilled underneath
|
||||
m.Start(ctx, ifaceName)
|
||||
}(iface.Name())
|
||||
```
|
||||
|
||||
- **Never wait on a channel while holding a lock the sender needs.** Copy what you
|
||||
need out from under the lock, release it, then wait.
|
||||
|
||||
```go
|
||||
func (m *Manager) Stop() {
|
||||
m.mu.Lock()
|
||||
cancel, done := m.cancel, m.done
|
||||
m.mu.Unlock()
|
||||
if cancel != nil {
|
||||
cancel()
|
||||
<-done
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- **`Stop`/`Close` must be idempotent** — guard on an already-stopped flag or a
|
||||
nil cancel — and must release the state they guarded. Clear maps and caches;
|
||||
a cancelled goroutine holding a live map still pins that memory. Note that a
|
||||
nil map only panics on writes; reads and iteration behave like an empty map,
|
||||
so where post-close use must be rejected, check the stopped flag explicitly.
|
||||
- **Publish coupled state only after every fallible step succeeds.** When several
|
||||
fields form an invariant, build them into locals and assign them to the receiver
|
||||
at the end. Assigning as you go leaves the object half-initialized when a later
|
||||
step fails, so a readiness predicate reports ready while a coupled field is nil.
|
||||
If an earlier step already had an external side effect — a created chain, an
|
||||
opened handle, an inserted rule — roll it back before returning the error.
|
||||
- **Clean up what you own on constructor error paths.** Once a constructor has
|
||||
started something, every later error path must undo it: cancel a goroutine and
|
||||
wait for it to exit, stop a ticker, close a watcher. The object is never
|
||||
returned, so its `Close` will never run.
|
||||
- **A failed `Start` must undo everything it started.** When a component brings up
|
||||
several subsystems in sequence — connection manager, watchers, routing, DNS,
|
||||
flow, persisted state — a failure partway through has to tear down the ones
|
||||
already running, not just close the handle the error came from. Put the
|
||||
already-started guard *before* that teardown path, so a rejected second `Start`
|
||||
cannot dismantle the one that is running.
|
||||
|
||||
## Error handling
|
||||
|
||||
Use single-assignment form when the error is only needed inside the `if`:
|
||||
|
||||
```go
|
||||
// Good
|
||||
if err := someCall(); err != nil {
|
||||
return fmt.Errorf("context: %w", err)
|
||||
}
|
||||
|
||||
// Bad - unnecessary split
|
||||
err := someCall()
|
||||
if err != nil {
|
||||
return fmt.Errorf("context: %w", err)
|
||||
}
|
||||
```
|
||||
|
||||
Use multiple assignment when the value is needed after the block:
|
||||
|
||||
```go
|
||||
result, err := someCall()
|
||||
if err != nil {
|
||||
return fmt.Errorf("context: %w", err)
|
||||
}
|
||||
```
|
||||
|
||||
Add short, meaningful context, and **do not** start `fmt.Errorf` messages with
|
||||
obvious words like "failed to" or "error":
|
||||
|
||||
```go
|
||||
// Good
|
||||
return fmt.Errorf("parse remote address: %w", err)
|
||||
return fmt.Errorf("listen on %s: %w", addr, err)
|
||||
|
||||
// Bad
|
||||
return fmt.Errorf("failed to parse remote address: %w", err)
|
||||
return fmt.Errorf("error listening on %s: %w", addr, err)
|
||||
|
||||
// "failed" is fine in log messages
|
||||
log.Debugf("failed to parse remote address: %v", err)
|
||||
```
|
||||
|
||||
Skip the wrapping when a function only extracts or delegates and the wrap would
|
||||
add nothing:
|
||||
|
||||
```go
|
||||
func parseAddr(addr string) (string, int, error) {
|
||||
host, portStr, err := net.SplitHostPort(addr)
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
Log the errors you choose not to act on:
|
||||
|
||||
- `log.Debugf()` for errors that do not affect program flow but help debugging.
|
||||
- `log.Tracef()` for very verbose errors that would otherwise spam logs.
|
||||
- **Never ignore** errors from writes, network sends, or critical cleanup.
|
||||
- Close errors may be ignored for read-only operations; log them at debug for
|
||||
writes.
|
||||
|
||||
**Do not log and return the same error.** It gets reported twice, from two places,
|
||||
and the second reader cannot tell whether it happened once or twice. Return it and
|
||||
let the caller decide. The exception is an API handler that has already written a
|
||||
response. Internal helpers return errors rather than logging and swallowing them.
|
||||
|
||||
**Never return a typed nil as an error.** A nil `*MyError` stored in an `error`
|
||||
interface is not nil, so `err != nil` is true and callers take the failure path on
|
||||
success. Return the error only where it is actually set:
|
||||
|
||||
```go
|
||||
if _, err := conn.Write(buf); err != nil { // good
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
```
|
||||
|
||||
**Accumulate with `multierror` when an operation should continue past individual
|
||||
failures** — teardown, cleanup, or setup where partial success is acceptable.
|
||||
`client/errors.FormatErrorOrNil` returns nil for an empty accumulator, so callers
|
||||
still see a plain nil on full success:
|
||||
|
||||
```go
|
||||
func (m *Manager) Cleanup() error {
|
||||
var merr *multierror.Error
|
||||
for _, r := range m.resources {
|
||||
if err := r.Close(); err != nil {
|
||||
merr = multierror.Append(merr, fmt.Errorf("close %s: %w", r.Name, err))
|
||||
}
|
||||
}
|
||||
return nberrors.FormatErrorOrNil(merr)
|
||||
}
|
||||
```
|
||||
|
||||
| Scenario | Approach | Why |
|
||||
| --------------------- | --------------------- | ----------------------------------------- |
|
||||
| Cleanup / teardown | Accumulate | Clean up as much as possible |
|
||||
| Setup with rollback | Abort on first error | Partial state is invalid; undo what stuck |
|
||||
| Setup with partial OK | Accumulate | Degraded operation is still useful |
|
||||
|
||||
## Comments
|
||||
|
||||
Comment the **why**, never the **what**. Default to no comment, and add one only
|
||||
when a hidden constraint or workaround would surprise a future reader. Never
|
||||
reference the current task, PR, or your own changes in a comment.
|
||||
|
||||
```go
|
||||
// Bad - trailing comments explaining the obvious
|
||||
defer localConn.Close() // Close the connection
|
||||
if err != nil { // Check if error occurred
|
||||
|
||||
// Good
|
||||
defer localConn.Close()
|
||||
|
||||
// Good - explains a non-obvious constraint
|
||||
// Use incremental checksum update per RFC 1624 for performance.
|
||||
checksum = updateChecksum(checksum, oldPort, newPort)
|
||||
```
|
||||
|
||||
### Length budget
|
||||
|
||||
Neither of these is linter-enforced, so they are conventions the surrounding code
|
||||
mostly follows rather than hard limits:
|
||||
|
||||
- **Around 90 characters per line.** Wrap the comment rather than running well past
|
||||
it.
|
||||
- **Roughly 250 characters per comment**, about three wrapped lines. Doc comments
|
||||
on exported identifiers may exceed it when the API genuinely needs the
|
||||
explanation; inline comments inside a function body rarely should.
|
||||
|
||||
The budget is a smell detector, not a rule to game. Do not compress a needed
|
||||
explanation into cryptic shorthand to fit — if a block of code needs more than
|
||||
250 characters of prose, the code is doing too much. Fix the code:
|
||||
|
||||
- **Extract a named function.** A well-named function replaces the comment: the
|
||||
name says *what*, the body shows *how*, and the comment you no longer write
|
||||
was the *what* anyway. Clean Code calls this "explain yourself in code".
|
||||
- **Extract a named constant or predicate.** `if isExpiredSetupKey(key)` needs
|
||||
no comment; `if key.ExpiresAt.Before(now) && !key.Revoked && key.UsageLimit > 0`
|
||||
does.
|
||||
- **Keep the surviving comment for the why** — the RFC, the kernel quirk, the
|
||||
ordering constraint. That part is usually one or two lines.
|
||||
|
||||
### Long switch and if/else chains
|
||||
|
||||
A `switch` whose cases carry multi-line explanations is the usual place this
|
||||
budget is breached, and the comment is a symptom. In order of preference:
|
||||
|
||||
1. **Extract each case body into a named function.** The case becomes one line,
|
||||
the name carries the meaning, and the switch reads as a table of contents.
|
||||
2. **Replace the switch with a lookup table** — `map[Kind]handlerFunc` — when the
|
||||
branches are uniform. Adding a case stops meaning editing a growing function.
|
||||
3. **Replace conditional with polymorphism** when branches vary by type and the
|
||||
same switch shape starts appearing in more than one place. Clean Code's rule
|
||||
of thumb: tolerate a switch statement if it appears **once**, is buried in a
|
||||
factory that returns an interface, and no other switch dispatches on the same
|
||||
type. A second switch over the same enum is the signal to introduce the
|
||||
interface.
|
||||
|
||||
Do not restructure a switch purely to satisfy the budget when the cases are one
|
||||
line each and self-evident — a flat, boring `switch` over an enum is fine and
|
||||
needs no comments at all.
|
||||
|
||||
Explanatory comments in tests are welcome — they document the scenario being set
|
||||
up, and the 250-character budget does not apply to them.
|
||||
|
||||
## Testing
|
||||
|
||||
- **Unit tests** live beside the code as `_test.go`. `make test-unit` runs the
|
||||
host-safe set with `-tags devcert` and no sudo.
|
||||
- **Privileged tests** carry the `privileged` build tag and mutate host
|
||||
networking. They run through `make test-privileged`, inside a Docker container
|
||||
with `NET_ADMIN`. Never bypass that harness by running them directly on the
|
||||
host.
|
||||
- **End-to-end suites** live in `e2e/` with a shared harness.
|
||||
- **Test real behavior, not API existence.** Assert on the observable end state
|
||||
a consumer would see — bytes that arrived, the packet after translation, the
|
||||
row after the write — not merely that a method exists or returns an error.
|
||||
- **Avoid mocks for code we own.** Exercise the real store, manager, or
|
||||
controller and assert what the caller actually receives.
|
||||
- **`require` for setup and preconditions, `assert` for the conditions under
|
||||
test.** Use `require` whenever a later line would panic or be meaningless
|
||||
otherwise.
|
||||
- **Message guidance:** optional for `NoError`/`Error`; always give context for
|
||||
comparison, boolean, and collection assertions.
|
||||
- **Reproduce a bug before fixing it.** Write the test, watch it fail *for the
|
||||
reason you expect* — a test that fails for an unrelated reason proves nothing —
|
||||
then apply the fix and confirm it passes. Add the thin surrounding cases while
|
||||
you are there.
|
||||
- **Use `t.Setenv`** rather than `os.Setenv` so the previous value is restored on
|
||||
cleanup. To test the unset case, call `t.Setenv` first to register the restore,
|
||||
then `os.Unsetenv`.
|
||||
- **Prefer `t.Cleanup` over `defer`** in any test with parallel subtests: the
|
||||
parent function returns, running its `defer`s, while parallel subtests are
|
||||
still suspended. Sequential subtests finish inside `t.Run`, so `defer` is safe
|
||||
there, but `t.Cleanup` works in both cases.
|
||||
- **Explanatory comments in tests are welcome.** Describe the scenario being set
|
||||
up; the comment budget below does not apply to them.
|
||||
|
||||
```go
|
||||
server, err := StartTestServer()
|
||||
require.NoError(t, err, "Test server setup must succeed")
|
||||
defer server.Close()
|
||||
|
||||
result, err := client.DoOperation()
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, expectedResult, result, "Result should match expected")
|
||||
```
|
||||
|
||||
## Pitfalls
|
||||
|
||||
- **The agent runs as root.** Anything touching routing, firewall, DNS, or the
|
||||
interface can take a user's machine off the network. Prefer a reversible
|
||||
change and make sure cleanup runs on every exit path.
|
||||
- **Management has two account loaders** (GORM and pgx). Adding a relation to an
|
||||
account often means updating both, or it silently comes back empty in
|
||||
production.
|
||||
- **`go test ./...` without `-tags devcert` skips tests** that need the
|
||||
development certificate. Use `make test-unit`.
|
||||
- **`make lint` only checks the diff against `origin/main`.** CI runs
|
||||
`make lint-all`; run it too before pushing a large change.
|
||||
- **Protos are consumed by released clients.** An old agent must keep working
|
||||
against a new Management, so fields are added, never renumbered or removed.
|
||||
- **Windows requires the wintun driver**, and the daemon serves a named pipe
|
||||
(`npipe://netbird`) rather than loopback TCP. Loopback TCP carries no caller
|
||||
identity, so privileged operations are refused over it.
|
||||
|
||||
## Commits, PRs, releases
|
||||
|
||||
- **PR titles must start with a bracketed tag.** Before you propose a title,
|
||||
**read [`.github/workflows/pr-title-check.yml`](.github/workflows/pr-title-check.yml)
|
||||
and take the allowed tags from the `allowedTags` array in that file.** It is
|
||||
the only source of truth, it changes as components are added, and the check
|
||||
runs on every title edit — a tag that is not in that array is a red build. Do
|
||||
not rely on a list memorized from anywhere else, including this file.
|
||||
|
||||
```text
|
||||
[client] Authorize daemon IPC callers by their local identity
|
||||
[management,client] Add MDM policy support
|
||||
```
|
||||
|
||||
Multiple tags are comma-separated inside one pair of brackets. Match the tag
|
||||
to the component you actually changed, not to the one you read the most.
|
||||
|
||||
- **Use the repository's PR template.** Fill in
|
||||
[`.github/pull_request_template.md`](.github/pull_request_template.md) rather
|
||||
than replacing it with your own summary: describe the change, link the issue,
|
||||
tick the checklist honestly (including "ran locally" and "single purpose"),
|
||||
and complete the documentation section. Do not tick a box you have not
|
||||
verified, and do not delete rows that do not apply — the docs gate in CI reads
|
||||
that section and fails when it is missing.
|
||||
|
||||
- **Keep the PR description short.** Under 1000 words on top of the template's
|
||||
own text, and usually far less — a few paragraphs. Reviewers read the diff;
|
||||
the description exists to explain what the diff cannot say for itself. This is
|
||||
well below what an agent will produce by default, so cut before you post.
|
||||
|
||||
- **Body: why before what.** Lead with the problem and the reason for this
|
||||
approach, then the shape of the change. No bullet list of files changed, no
|
||||
per-function walkthrough, no restating the diff in prose, no trailing summary
|
||||
section, no self-congratulatory closing line.
|
||||
|
||||
- **No `Co-Authored-By` or tool-attribution trailers in the PR description**,
|
||||
and none in commits either. Contributors own their contributions. Whatever
|
||||
tooling produced the diff, the person opening the PR is its author: they have
|
||||
read every line, they can explain why it works, they can answer review
|
||||
questions without going back to a model, and they are accountable for the
|
||||
consequences of merging it. Do not add a trailer, footer, or description line
|
||||
that spreads that ownership onto a tool.
|
||||
|
||||
- **Commit subjects follow the same `[scope] Subject` convention.** Keep the
|
||||
subject short, and use the body for why before what. No bullet lists of files
|
||||
changed.
|
||||
|
||||
- **Push review fixes as separate commits.** The PR is squashed on merge, so
|
||||
there is no reason to rewrite history mid-review; many small commits make the
|
||||
re-review readable.
|
||||
|
||||
- **Do not force-push a branch that is under review.** A force-push detaches
|
||||
existing review comments from the lines they were written against, destroys
|
||||
the "changes since your last review" diff a reviewer relies on, and discards
|
||||
the CI history that showed which commit broke what. Add commits instead —
|
||||
including for fixups and reverts. Force-push only when there is no
|
||||
alternative: a rebase to clear a genuine conflict, or removing a secret or a
|
||||
large binary that was committed by mistake. When you must, ask the user first,
|
||||
then say so in a PR comment so reviewers know their anchors moved. Never
|
||||
force-push `main`, and never force-push a branch you do not own.
|
||||
|
||||
- **One PR, one purpose.** Split refactors out of fixes and fixes out of
|
||||
features.
|
||||
|
||||
- **Keep the PR small.** Size is the single strongest predictor of how long a PR
|
||||
waits. Aim for **under ~400 changed lines across under ~20 files**; past
|
||||
roughly **1000 lines or 50 files** a community PR is likely to be sent back to
|
||||
be split, or left unreviewed until it is. Large PRs from outside the core team
|
||||
may be blocked outright when the size was never agreed in the ticket —
|
||||
reviewing a sprawling change against a privileged networking daemon is a
|
||||
security risk in itself, not just a time cost.
|
||||
|
||||
Judge the size by hand-written code: exclude generated output, `go.sum`,
|
||||
vendored files, and test fixtures from the estimate, but do not use their
|
||||
presence to argue a 3000-line PR is small.
|
||||
|
||||
When a change genuinely cannot be small — a protocol migration, a
|
||||
cross-component rename — agree the split in the ticket **before** writing
|
||||
code, and land it as a sequence of PRs that each build, test, and make sense
|
||||
on their own. Propose that split to the user rather than opening one large PR
|
||||
and hoping.
|
||||
|
||||
Prefer GitHub's stacked pull requests for such a sequence, rather than
|
||||
hand-managing base branches: open each PR against the branch below it instead of
|
||||
`main`, so every PR's diff shows only its own change. Merging a layer retargets
|
||||
the PRs above it, and branch protections and required checks on the base branch
|
||||
still apply to each one.
|
||||
|
||||
- **User-facing changes need a docs PR** in
|
||||
[netbirdio/docs](https://github.com/netbirdio/docs), linked from the PR
|
||||
description.
|
||||
|
||||
## After you push: CI and review bots
|
||||
|
||||
Opening the PR is not the end of the task. Watch the run, read what the bots
|
||||
say, and drive the PR to green before you report the work as done.
|
||||
|
||||
```bash
|
||||
gh pr checks <pr> --watch # all checks, live
|
||||
gh run view <run-id> --log-failed # only the failing steps
|
||||
gh pr view <pr> --comments # bot and human review comments
|
||||
```
|
||||
|
||||
**Never report a change as finished while checks are pending or red**, and never
|
||||
describe a red PR as passing. If you ran out of turn before CI finished, say
|
||||
which checks were still running.
|
||||
|
||||
### The checks
|
||||
|
||||
- **Go tests** — `golang-test-{linux,darwin,windows,freebsd}.yml`, sharded per
|
||||
component. A failure in a component you did not touch is usually a real
|
||||
interaction, not noise; read the log before assuming flake.
|
||||
- **golangci-lint** — `golangci-lint.yml` runs the full repository, while
|
||||
`make lint` only checks your diff. A clean local lint does not guarantee green
|
||||
CI on a large change.
|
||||
- **PR Title Check** — `pr-title-check.yml`, see above.
|
||||
- **Codecov** — uploaded from the Linux test workflow with per-component flags
|
||||
(`unit,client`, `unit,management`, `unit,relay`, `unit,proxy`, `unit,signal`,
|
||||
`integration,management`). Coverage on new code should not go backwards. Add
|
||||
tests for the paths you introduced; do not adjust thresholds or exclude files
|
||||
to clear the report.
|
||||
- **CodeRabbit** — configured in [`.coderabbit.yaml`](.coderabbit.yaml): `chill`
|
||||
profile, auto-review on every non-draft PR, TypeScript/JavaScript/SVG paths
|
||||
filtered out. Chat auto-reply is on, so `@coderabbitai` in a comment reaches
|
||||
it.
|
||||
- **SonarCloud** — project `netbirdio_netbird`, quality gate on new code (bugs,
|
||||
vulnerabilities, code smells, duplication, coverage).
|
||||
- **Snyk** — dependency and code scanning.
|
||||
|
||||
Sonar and Snyk report as GitHub App checks rather than workflows in this
|
||||
repository, so their detail lives on the PR check, not in the Actions logs.
|
||||
|
||||
### Handling bot findings
|
||||
|
||||
- **Read every comment and act on it.** Either fix it, or reply with the reason
|
||||
it does not apply. Do not bulk-resolve threads to clear the count, and do not
|
||||
silently ignore a finding because the check is advisory.
|
||||
- **Bots are frequently wrong here.** NetBird has privileged, platform-specific,
|
||||
and concurrency-heavy code that static analysis reads poorly. A confident
|
||||
CodeRabbit or Sonar comment can still be nonsense. Verify the claim against
|
||||
the code before you change anything — never edit correct code just to silence
|
||||
a bot.
|
||||
- **Security findings get the opposite default.** For a Snyk or Sonar
|
||||
vulnerability, or a CodeRabbit comment about authentication, authorization,
|
||||
certificate verification, or key handling, assume it is real until you have
|
||||
disproved it. Surface it to the user rather than dismissing it yourself.
|
||||
- **A new vulnerable dependency is a stop.** Bumping or replacing dependencies
|
||||
needs the user's decision, as above.
|
||||
- **Never change a workflow, threshold, lint exclusion, or bot config to make a
|
||||
check pass.** If a check is genuinely wrong, say so and let the user decide.
|
||||
- **Do not paper over flakes with blind re-runs.** Identify the failure first. If
|
||||
it is a known flake, name it; if you cannot tell, report it as unresolved
|
||||
rather than re-running until it goes green.
|
||||
|
||||
## Discussion and support
|
||||
|
||||
- Discussions: <https://github.com/netbirdio/netbird/discussions>
|
||||
- Slack: <https://docs.netbird.io/slack-url>
|
||||
- Docs: <https://docs.netbird.io>
|
||||
- Security: <https://github.com/netbirdio/netbird/security/policy> — never in public
|
||||
- Contribution process: [CONTRIBUTING.md](CONTRIBUTING.md)
|
||||
1
CLAUDE.md
Normal file
1
CLAUDE.md
Normal file
@@ -0,0 +1 @@
|
||||
See [AGENTS.md](AGENTS.md) for the agent guidelines in this repository.
|
||||
@@ -66,11 +66,41 @@ Typical bug fixes, internal refactors, documentation updates, and tests do not
|
||||
need a design discussion, but should still be tied to an issue so the work is
|
||||
visible and nobody duplicates it.
|
||||
|
||||
### Using AI coding agents
|
||||
|
||||
We have no policy for or against using an AI agent to write NetBird code. That
|
||||
choice is yours, and we are not going to interrogate anyone about their tools.
|
||||
|
||||
What we do have is a lot of incoming contributions that were plainly drafted with
|
||||
one, and enough experience reviewing them to see the same avoidable problems
|
||||
again and again: no ticket behind the change, a diff far too large to review, a
|
||||
description longer than the code it describes, an approach that was never going
|
||||
to be accepted, and an author who cannot answer questions about their own PR.
|
||||
None of that is caused by the tooling — it is what happens when a tool is pointed
|
||||
at a repository whose expectations it has never been told.
|
||||
|
||||
So rather than a rule, there is a guide. [AGENTS.md](AGENTS.md) restates the
|
||||
expectations from this document in the form agents read automatically
|
||||
(`CLAUDE.md` points to it), so pointing your tool at the repository is usually
|
||||
enough. Among other things it tells the agent to ask you for the
|
||||
discussion or issue before drafting a PR, to keep the change small and
|
||||
single-purpose, to run the tests locally, to use this repository's PR template
|
||||
and title tags, and to write a description a reviewer can get through.
|
||||
|
||||
The guardrails are the point, and they are the same ones we apply to everyone: an
|
||||
agreed ticket, a change you have actually run, a diff small enough to review with
|
||||
care, and an author who can explain it. Whatever wrote the diff, you are its
|
||||
author — you own every line you submit and the consequences of opening a PR with it.
|
||||
|
||||
We may assess whether a contribution is maintainable and whether its merged code
|
||||
aligns with our security standards and design expectations.
|
||||
|
||||
## Contents
|
||||
|
||||
- [Contributing to NetBird](#contributing-to-netbird)
|
||||
- [Ticket first, PR second](#ticket-first-pr-second)
|
||||
- [High-risk areas](#high-risk-areas)
|
||||
- [Using AI coding agents](#using-ai-coding-agents)
|
||||
- [Contents](#contents)
|
||||
- [Code of conduct](#code-of-conduct)
|
||||
- [Directory structure](#directory-structure)
|
||||
@@ -82,6 +112,7 @@ visible and nobody duplicates it.
|
||||
- [Test suite](#test-suite)
|
||||
- [Checklist before submitting a PR](#checklist-before-submitting-a-pr)
|
||||
- [When we close a PR](#when-we-close-a-pr)
|
||||
- [Translations](#translations)
|
||||
- [Other project repositories](#other-project-repositories)
|
||||
- [Contributor License Agreement](#contributor-license-agreement)
|
||||
|
||||
@@ -582,6 +613,17 @@ A closed PR is not a rejected idea. Take it back to the
|
||||
[discussion](https://github.com/netbirdio/netbird/discussions), settle the
|
||||
approach, and reopen the work from there.
|
||||
|
||||
## Translations
|
||||
|
||||
Desktop UI translations are not contributed through pull requests. Translate on
|
||||
[Crowdin](https://crowdin.com/project/netbird) instead: no ticket needed, just
|
||||
join the project and pick your language. Crowdin syncs with this repository and
|
||||
opens the service PRs itself, so hand-edited locale files would conflict with
|
||||
the next sync. Style, terminology, and review guidance live in
|
||||
[client/ui/i18n/TRANSLATING.md](client/ui/i18n/TRANSLATING.md). To request a
|
||||
language the project does not offer yet, ask on the Crowdin project page or in
|
||||
a [discussion](https://github.com/netbirdio/netbird/discussions).
|
||||
|
||||
## Other project repositories
|
||||
|
||||
NetBird project is composed of 3 main repositories:
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
nbAnonymize "github.com/netbirdio/netbird/client/anonymize"
|
||||
"github.com/netbirdio/netbird/client/iface/device"
|
||||
"github.com/netbirdio/netbird/client/internal"
|
||||
"github.com/netbirdio/netbird/client/internal/debug"
|
||||
@@ -25,6 +26,8 @@ import (
|
||||
"github.com/netbirdio/netbird/client/internal/routemanager"
|
||||
"github.com/netbirdio/netbird/client/internal/stdnet"
|
||||
"github.com/netbirdio/netbird/client/net"
|
||||
"github.com/netbirdio/netbird/client/netstate"
|
||||
"github.com/netbirdio/netbird/client/netsweep"
|
||||
"github.com/netbirdio/netbird/client/system"
|
||||
"github.com/netbirdio/netbird/formatter"
|
||||
"github.com/netbirdio/netbird/route"
|
||||
@@ -32,10 +35,12 @@ import (
|
||||
types "github.com/netbirdio/netbird/upload-server/types"
|
||||
)
|
||||
|
||||
// ConnectionListener export internal Listener for mobile
|
||||
type ConnectionListener interface {
|
||||
peer.Listener
|
||||
}
|
||||
// AnonymizeLevelDefault and AnonymizeLevelStrict are the accepted
|
||||
// anonymizeLevel values for DebugBundle.
|
||||
const (
|
||||
AnonymizeLevelDefault = nbAnonymize.LevelDefaultString
|
||||
AnonymizeLevelStrict = nbAnonymize.LevelStrictString
|
||||
)
|
||||
|
||||
// TunAdapter export internal TunAdapter for mobile
|
||||
type TunAdapter interface {
|
||||
@@ -57,6 +62,12 @@ type DnsReadyListener interface {
|
||||
dns.ReadyListener
|
||||
}
|
||||
|
||||
// TunSettings is a snapshot of the settings the TUN device is rebuilt with
|
||||
type TunSettings struct {
|
||||
Routes string
|
||||
SearchDomains string
|
||||
}
|
||||
|
||||
func init() {
|
||||
formatter.SetLogcatFormatter(log.StandardLogger())
|
||||
}
|
||||
@@ -71,11 +82,20 @@ type Client struct {
|
||||
deviceName string
|
||||
uiVersion string
|
||||
networkChangeListener listener.NetworkChangeListener
|
||||
// netState outlives engine restarts: it mirrors the OS connectivity, not
|
||||
// the engine lifecycle. Run and RunWithoutLogin inject it into each new
|
||||
// ConnectClient, which distributes it to every reconnection loop.
|
||||
netState *netstate.State
|
||||
|
||||
// sweeper also outlives engine restarts; NotifyNetworkChange sweeps it.
|
||||
sweeper *netsweep.Sweeper
|
||||
|
||||
stateMu sync.RWMutex
|
||||
connectClient *internal.ConnectClient
|
||||
config *profilemanager.Config
|
||||
cacheDir string
|
||||
// Identifies the running profile for the SSO login hint; see profile_state.go.
|
||||
cfgPath string
|
||||
|
||||
stateChangeMu sync.Mutex
|
||||
stateChangeSubID string
|
||||
@@ -94,13 +114,19 @@ type Client struct {
|
||||
|
||||
extendMu sync.Mutex
|
||||
extendCancel context.CancelFunc
|
||||
|
||||
// The file drop handle survives engine restarts so the UI keeps one listener
|
||||
// registration and one history view across reconnects. See fileDropFor.
|
||||
fileDropMu sync.Mutex
|
||||
fileDrop *FileDrop
|
||||
}
|
||||
|
||||
func (c *Client) setState(cfg *profilemanager.Config, cacheDir string, cc *internal.ConnectClient) {
|
||||
func (c *Client) setState(cfg *profilemanager.Config, cacheDir string, cfgPath string, cc *internal.ConnectClient) {
|
||||
c.stateMu.Lock()
|
||||
defer c.stateMu.Unlock()
|
||||
c.config = cfg
|
||||
c.cacheDir = cacheDir
|
||||
c.cfgPath = cfgPath
|
||||
c.connectClient = cc
|
||||
}
|
||||
|
||||
@@ -110,6 +136,16 @@ func (c *Client) stateSnapshot() (*profilemanager.Config, string, *internal.Conn
|
||||
return c.config, c.cacheDir, c.connectClient
|
||||
}
|
||||
|
||||
// authSnapshot returns the config together with the path it was loaded from, in
|
||||
// one lock: the path identifies the profile whose account email backs the login
|
||||
// hint, so reading it separately could pair one profile's config with another's
|
||||
// hint when a profile switch lands in between.
|
||||
func (c *Client) authSnapshot() (*profilemanager.Config, string, *internal.ConnectClient) {
|
||||
c.stateMu.RLock()
|
||||
defer c.stateMu.RUnlock()
|
||||
return c.config, c.cfgPath, c.connectClient
|
||||
}
|
||||
|
||||
func (c *Client) getConnectClient() *internal.ConnectClient {
|
||||
c.stateMu.RLock()
|
||||
defer c.stateMu.RUnlock()
|
||||
@@ -129,6 +165,8 @@ func NewClient(androidSDKVersion int, deviceName string, uiVersion string, tunAd
|
||||
recorder: peer.NewRecorder(""),
|
||||
ctxCancelLock: &sync.Mutex{},
|
||||
networkChangeListener: networkChangeListener,
|
||||
netState: netstate.New(),
|
||||
sweeper: netsweep.New(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -162,15 +200,17 @@ func (c *Client) Run(platformFiles PlatformFiles, urlOpener URLOpener, isAndroid
|
||||
defer c.ctxCancel()
|
||||
c.ctxCancelLock.Unlock()
|
||||
|
||||
auth := NewAuthWithConfig(ctx, cfg)
|
||||
auth := NewAuthWithConfig(ctx, cfg, cfgFile)
|
||||
err = auth.login(urlOpener, isAndroidTV)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// todo do not throw error in case of cancelled context
|
||||
ctx = internal.CtxInitState(ctx)
|
||||
connectClient := internal.NewConnectClient(ctx, cfg, c.recorder)
|
||||
c.setState(cfg, cacheDir, connectClient)
|
||||
connectClient := internal.NewConnectClient(ctx, cfg, c.recorder,
|
||||
internal.WithNetworkState(c.netState), internal.WithSweeper(c.sweeper))
|
||||
c.attachFileDrop(connectClient, cfgFile)
|
||||
c.setState(cfg, cacheDir, cfgFile, connectClient)
|
||||
// This path runs the interactive SSO flow, so reaching here means the peer
|
||||
// is authenticated again — release the latch Status() reports from. Clear
|
||||
// only once the fresh connect client is installed: until then Status()
|
||||
@@ -210,8 +250,10 @@ func (c *Client) RunWithoutLogin(platformFiles PlatformFiles, dns *DNSList, dnsR
|
||||
|
||||
// todo do not throw error in case of cancelled context
|
||||
ctx = internal.CtxInitState(ctx)
|
||||
connectClient := internal.NewConnectClient(ctx, cfg, c.recorder)
|
||||
c.setState(cfg, cacheDir, connectClient)
|
||||
connectClient := internal.NewConnectClient(ctx, cfg, c.recorder,
|
||||
internal.WithNetworkState(c.netState), internal.WithSweeper(c.sweeper))
|
||||
c.attachFileDrop(connectClient, cfgFile)
|
||||
c.setState(cfg, cacheDir, cfgFile, connectClient)
|
||||
return connectClient.RunOnAndroid(c.tunAdapter, c.iFaceDiscover, c.networkChangeListener, slices.Clone(dns.items), dnsReadyListener, stateFile, cacheDir)
|
||||
}
|
||||
|
||||
@@ -240,9 +282,47 @@ func (c *Client) RenewTun(fd int) error {
|
||||
return e.RenewTun(fd)
|
||||
}
|
||||
|
||||
func (c *Client) GetTunSettings() (*TunSettings, error) {
|
||||
cc := c.getConnectClient()
|
||||
if cc == nil {
|
||||
return nil, fmt.Errorf("engine not running")
|
||||
}
|
||||
|
||||
e := cc.Engine()
|
||||
if e == nil {
|
||||
return nil, fmt.Errorf("engine not initialized")
|
||||
}
|
||||
|
||||
routes, searchDomains := e.TunSettings()
|
||||
return &TunSettings{
|
||||
Routes: strings.Join(routes, ";"),
|
||||
SearchDomains: strings.Join(searchDomains, ";"),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// SetNetworkAvailable feeds OS-reported network availability into the client.
|
||||
// While unavailable, the internal reconnect loops suspend their attempts and
|
||||
// the connection listener reports NoNetwork instead of Connecting; when
|
||||
// availability returns, the loops resume immediately with a fresh backoff.
|
||||
func (c *Client) SetNetworkAvailable(available bool) {
|
||||
c.netState.Set(available)
|
||||
c.recorder.SetNetworkAvailable(available)
|
||||
}
|
||||
|
||||
// NotifyNetworkChange marks the management, signal and relay connections
|
||||
// stale after the OS switched networks and schedules a sweep that cuts
|
||||
// whatever has not redialed on the new network by then. The engine and the
|
||||
// TUN device stay untouched.
|
||||
func (c *Client) NotifyNetworkChange() {
|
||||
c.sweeper.MarkNetworkChange()
|
||||
log.Infof("network change: connections marked stale")
|
||||
}
|
||||
|
||||
// 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) {
|
||||
// It works both with and without a running engine. anonymizeLevel is "default"
|
||||
// or "strict"; strict also anonymizes internal IP ranges, peer names, and
|
||||
// WireGuard public keys, and implies anonymize.
|
||||
func (c *Client) DebugBundle(platformFiles PlatformFiles, anonymize bool, anonymizeLevel string) (string, error) {
|
||||
cfg, cacheDir, cc := c.stateSnapshot()
|
||||
|
||||
// If the engine hasn't been started, load config from disk
|
||||
@@ -261,6 +341,7 @@ func (c *Client) DebugBundle(platformFiles PlatformFiles, anonymize bool) (strin
|
||||
InternalConfig: cfg,
|
||||
StatusRecorder: c.recorder,
|
||||
TempDir: cacheDir,
|
||||
StatePath: platformFiles.StateFilePath(),
|
||||
}
|
||||
|
||||
if cc != nil {
|
||||
@@ -284,6 +365,7 @@ func (c *Client) DebugBundle(platformFiles PlatformFiles, anonymize bool) (strin
|
||||
deps,
|
||||
debug.BundleConfig{
|
||||
Anonymize: anonymize,
|
||||
AnonymizeLevel: nbAnonymize.ParseLevel(anonymizeLevel),
|
||||
IncludeSystemInfo: true,
|
||||
},
|
||||
)
|
||||
@@ -476,7 +558,11 @@ func (c *Client) OnUpdatedHostDNS(list *DNSList) error {
|
||||
|
||||
// SetConnectionListener set the network connection listener
|
||||
func (c *Client) SetConnectionListener(listener ConnectionListener) {
|
||||
c.recorder.SetConnectionListener(listener)
|
||||
if listener == nil {
|
||||
c.recorder.RemoveConnectionListener()
|
||||
return
|
||||
}
|
||||
c.recorder.SetConnectionListener(connectionListenerAdapter{listener})
|
||||
}
|
||||
|
||||
// RemoveConnectionListener remove connection listener
|
||||
|
||||
78
client/android/client_filedrop.go
Normal file
78
client/android/client_filedrop.go
Normal file
@@ -0,0 +1,78 @@
|
||||
//go:build android
|
||||
|
||||
package android
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal"
|
||||
)
|
||||
|
||||
// FileDrop returns the handle of the active profile, creating it on first use.
|
||||
// The UI calls this to list transfers and change settings while disconnected.
|
||||
func (c *Client) FileDrop(configDir string) (*FileDrop, error) {
|
||||
profile, err := NewProfileManager(configDir).GetActiveProfile()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get active profile: %w", err)
|
||||
}
|
||||
return c.fileDropFor(configDir, profile.ID)
|
||||
}
|
||||
|
||||
// fileDropFor returns the handle of one profile, replacing the cached one when
|
||||
// the profile changed. The listener is carried over so a profile switch does not
|
||||
// silence the UI.
|
||||
func (c *Client) fileDropFor(configDir, profileID string) (*FileDrop, error) {
|
||||
c.fileDropMu.Lock()
|
||||
|
||||
if c.fileDrop != nil && c.fileDrop.ProfileID() == profileID {
|
||||
fd := c.fileDrop
|
||||
c.fileDropMu.Unlock()
|
||||
return fd, nil
|
||||
}
|
||||
|
||||
fd, err := NewFileDrop(configDir, profileID)
|
||||
if err != nil {
|
||||
c.fileDropMu.Unlock()
|
||||
return nil, err
|
||||
}
|
||||
ensureFileDropDestination(fd)
|
||||
|
||||
old := c.fileDrop
|
||||
if old != nil {
|
||||
fd.SetListener(old.Listener())
|
||||
}
|
||||
c.fileDrop = fd
|
||||
c.fileDropMu.Unlock()
|
||||
|
||||
// Closing waits out the in-flight uploads of the profile being left, which is
|
||||
// far too long to hold the lock every caller of this goes through.
|
||||
if old != nil {
|
||||
if err := old.Close(); err != nil {
|
||||
log.Warnf("failed to close previous file drop manager: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
return fd, nil
|
||||
}
|
||||
|
||||
// attachFileDrop hands the connect client the file drop manager of the profile
|
||||
// the engine is starting for. The profile is derived from the config path rather
|
||||
// than read from the active profile state, so a switch racing the startup cannot
|
||||
// pair one profile's engine with another's transfers. A failure is not fatal:
|
||||
// the tunnel is worth more than the feature, so the engine runs on without it.
|
||||
func (c *Client) attachFileDrop(cc *internal.ConnectClient, cfgFile string) {
|
||||
configDir, profileID, err := profileLocationFor(cfgFile)
|
||||
if err != nil {
|
||||
log.Warnf("file drop is unavailable: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
fd, err := c.fileDropFor(configDir, profileID)
|
||||
if err != nil {
|
||||
log.Warnf("file drop is unavailable: %v", err)
|
||||
return
|
||||
}
|
||||
cc.SetFileDropManager(fd.manager)
|
||||
}
|
||||
41
client/android/connection_listener.go
Normal file
41
client/android/connection_listener.go
Normal file
@@ -0,0 +1,41 @@
|
||||
//go:build android
|
||||
|
||||
package android
|
||||
|
||||
import (
|
||||
"github.com/netbirdio/netbird/client/internal/peer"
|
||||
)
|
||||
|
||||
// Client state values delivered via ConnectionListener.OnStateChanged,
|
||||
// re-exported as basic constants so gomobile emits them into the generated
|
||||
// Java bindings. They mirror peer.ClientState*: append-only, never reorder.
|
||||
const (
|
||||
ClientStateDisconnected = int(peer.ClientStateDisconnected)
|
||||
ClientStateConnected = int(peer.ClientStateConnected)
|
||||
ClientStateConnecting = int(peer.ClientStateConnecting)
|
||||
ClientStateDisconnecting = int(peer.ClientStateDisconnecting)
|
||||
ClientStateNoNetwork = int(peer.ClientStateNoNetwork)
|
||||
)
|
||||
|
||||
// ConnectionListener export internal Listener for mobile. It mirrors
|
||||
// peer.Listener with OnStateChanged taking a plain int (one of the
|
||||
// ClientState* constants), because gomobile cannot bind named types.
|
||||
type ConnectionListener interface {
|
||||
OnStateChanged(state int)
|
||||
OnConnected()
|
||||
OnDisconnected()
|
||||
OnConnecting()
|
||||
OnDisconnecting()
|
||||
OnAddressChanged(string, string)
|
||||
OnPeersListChanged(int)
|
||||
}
|
||||
|
||||
// connectionListenerAdapter adapts the gomobile-facing ConnectionListener to
|
||||
// peer.Listener, converting the typed state to the int the binding carries.
|
||||
type connectionListenerAdapter struct {
|
||||
ConnectionListener
|
||||
}
|
||||
|
||||
func (a connectionListenerAdapter) OnStateChanged(state peer.ClientState) {
|
||||
a.ConnectionListener.OnStateChanged(int(state))
|
||||
}
|
||||
205
client/android/filedrop.go
Normal file
205
client/android/filedrop.go
Normal file
@@ -0,0 +1,205 @@
|
||||
//go:build android
|
||||
|
||||
package android
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/filedrop"
|
||||
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
||||
)
|
||||
|
||||
const filedropDataSubdir = "filedrop"
|
||||
|
||||
// FileDrop is the platform-facing handle on one profile's file drop state. It
|
||||
// outlives the engine: the manager keeps policy and history readable while the
|
||||
// tunnel is down, and sending simply fails until it comes back up.
|
||||
type FileDrop struct {
|
||||
mu sync.Mutex
|
||||
configDir string
|
||||
profileID string
|
||||
manager *filedrop.Manager
|
||||
listener FileDropListener
|
||||
}
|
||||
|
||||
// NewFileDrop opens the file drop state of the given profile.
|
||||
func NewFileDrop(configDir, profileID string) (*FileDrop, error) {
|
||||
if configDir == "" || profileID == "" {
|
||||
return nil, errors.New("file drop requires a config dir and profile ID")
|
||||
}
|
||||
|
||||
prefs, err := newProfilePrefs(configDir, profileID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
fd := &FileDrop{configDir: configDir, profileID: profileID}
|
||||
manager, err := filedrop.NewManager(filedrop.ManagerConfig{
|
||||
Profile: profilemanager.ID(profileID),
|
||||
DataDir: filepath.Join(configDir, filedropDataSubdir, profileID),
|
||||
Store: filedrop.NewProfileStore(prefs.prefs),
|
||||
Events: fd.publish,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create file drop manager: %w", err)
|
||||
}
|
||||
|
||||
fd.manager = manager
|
||||
return fd, nil
|
||||
}
|
||||
|
||||
// ProfileID returns the profile this handle belongs to.
|
||||
func (f *FileDrop) ProfileID() string {
|
||||
return f.profileID
|
||||
}
|
||||
|
||||
// SetListener installs the event listener, replacing any previous one.
|
||||
func (f *FileDrop) SetListener(listener FileDropListener) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.listener = listener
|
||||
}
|
||||
|
||||
// Listener returns the installed event listener, nil when there is none.
|
||||
func (f *FileDrop) Listener() FileDropListener {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return f.listener
|
||||
}
|
||||
|
||||
// RemoveListener stops event delivery.
|
||||
func (f *FileDrop) RemoveListener() {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.listener = nil
|
||||
}
|
||||
|
||||
// Send starts an asynchronous transfer and returns its local transfer ID.
|
||||
func (f *FileDrop) Send(peerKey, peerName, peerIP string, payloads *FileDropPayloads) (string, error) {
|
||||
if payloads == nil || payloads.Length() == 0 {
|
||||
return "", errors.New("nothing to send")
|
||||
}
|
||||
|
||||
addr, err := netip.ParseAddr(peerIP)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("parse peer address %q: %w", peerIP, err)
|
||||
}
|
||||
|
||||
id, err := f.manager.Send(filedrop.PeerKey(peerKey), peerName, addr.Unmap(), payloads.items)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(id), nil
|
||||
}
|
||||
|
||||
// Accept releases a pending incoming offer for download.
|
||||
func (f *FileDrop) Accept(transferID string) error {
|
||||
return f.manager.Accept(filedrop.OfferID(transferID))
|
||||
}
|
||||
|
||||
// Decline refuses a pending incoming offer.
|
||||
func (f *FileDrop) Decline(transferID string) error {
|
||||
return f.manager.Decline(filedrop.OfferID(transferID))
|
||||
}
|
||||
|
||||
// Cancel aborts a transfer in either direction.
|
||||
func (f *FileDrop) Cancel(transferID string) {
|
||||
f.manager.Cancel(filedrop.OfferID(transferID))
|
||||
}
|
||||
|
||||
// Transfers returns the history, newest first.
|
||||
func (f *FileDrop) Transfers() *FileDropTransferArray {
|
||||
transfers := f.manager.Transfers()
|
||||
items := make([]*FileDropTransfer, 0, len(transfers))
|
||||
for _, t := range transfers {
|
||||
items = append(items, toFileDropTransfer(t))
|
||||
}
|
||||
return &FileDropTransferArray{items: items}
|
||||
}
|
||||
|
||||
// Transfer returns one history entry, or nil when it is unknown.
|
||||
func (f *FileDrop) Transfer(transferID string) *FileDropTransfer {
|
||||
for _, t := range f.manager.Transfers() {
|
||||
if string(t.ID) == transferID {
|
||||
return toFileDropTransfer(t)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteTransfer removes one history entry, cancelling it when still live.
|
||||
func (f *FileDrop) DeleteTransfer(transferID string) {
|
||||
f.manager.DeleteTransfer(filedrop.OfferID(transferID))
|
||||
}
|
||||
|
||||
// Mode returns the base receiving mode.
|
||||
func (f *FileDrop) Mode() int {
|
||||
return int(f.manager.Policy().Get().Mode)
|
||||
}
|
||||
|
||||
// SetMode changes the base receiving mode.
|
||||
func (f *FileDrop) SetMode(mode int) error {
|
||||
return f.manager.Policy().SetMode(filedrop.Mode(mode))
|
||||
}
|
||||
|
||||
// DestinationDir returns the directory received files are delivered to.
|
||||
func (f *FileDrop) DestinationDir() string {
|
||||
return f.manager.DestinationDir()
|
||||
}
|
||||
|
||||
// SetDestinationDir persists the delivery directory. It must be a filesystem
|
||||
// path the app can write; content URIs are not paths, so the platform layer
|
||||
// moves files out of this directory afterwards.
|
||||
func (f *FileDrop) SetDestinationDir(dir string) error {
|
||||
return f.manager.SetDestinationDir(dir)
|
||||
}
|
||||
|
||||
// PeerRule returns the rule stored for one sender.
|
||||
func (f *FileDrop) PeerRule(peerKey string) int {
|
||||
return int(f.manager.Policy().Get().Senders[filedrop.PeerKey(peerKey)])
|
||||
}
|
||||
|
||||
// SetPeerRule sets or clears the exception for one sender.
|
||||
func (f *FileDrop) SetPeerRule(peerKey string, rule int) error {
|
||||
return f.manager.SetSenderRule(filedrop.PeerKey(peerKey), filedrop.SenderRule(rule))
|
||||
}
|
||||
|
||||
// Close stops the receiver and aborts every outgoing transfer.
|
||||
func (f *FileDrop) Close() error {
|
||||
f.RemoveListener()
|
||||
return f.manager.Close()
|
||||
}
|
||||
|
||||
func (f *FileDrop) publish(kind filedrop.EventKind, transfer filedrop.Transfer) {
|
||||
f.mu.Lock()
|
||||
listener := f.listener
|
||||
f.mu.Unlock()
|
||||
if listener == nil {
|
||||
return
|
||||
}
|
||||
listener.OnFileDropEvent(int(kind), toFileDropTransfer(transfer))
|
||||
}
|
||||
|
||||
// defaultFileDropDir is the app-private landing directory used until the
|
||||
// platform layer configures one.
|
||||
func defaultFileDropDir(configDir, profileID string) string {
|
||||
return filepath.Join(configDir, filedropDataSubdir, profileID, "incoming")
|
||||
}
|
||||
|
||||
// ensureFileDropDestination seeds the delivery directory on first use, so a
|
||||
// received file always has somewhere to land.
|
||||
func ensureFileDropDestination(fd *FileDrop) {
|
||||
if fd.DestinationDir() != "" {
|
||||
return
|
||||
}
|
||||
dir := defaultFileDropDir(fd.configDir, fd.profileID)
|
||||
if err := fd.SetDestinationDir(dir); err != nil {
|
||||
log.Warnf("failed to set default file drop destination: %v", err)
|
||||
}
|
||||
}
|
||||
122
client/android/filedrop_payload.go
Normal file
122
client/android/filedrop_payload.go
Normal file
@@ -0,0 +1,122 @@
|
||||
//go:build android
|
||||
|
||||
package android
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/filedrop"
|
||||
)
|
||||
|
||||
// FileSource opens the bytes of one outgoing item. Android hands out content URIs
|
||||
// rather than paths, so the platform layer owns opening and seeking.
|
||||
type FileSource interface {
|
||||
// Open returns a stream positioned at offset. It is called once per attempt,
|
||||
// and again from the start when a transfer resumes.
|
||||
Open(offset int64) (SourceStream, error)
|
||||
}
|
||||
|
||||
// SourceStream is the readable half of a FileSource.
|
||||
//
|
||||
// It returns each chunk instead of filling a caller-supplied buffer: gomobile
|
||||
// copies a []byte argument into a fresh Java array and never copies it back, so
|
||||
// a fill-my-buffer method would hand back the right length with no data. Only
|
||||
// the return value crosses the bridge intact.
|
||||
type SourceStream interface {
|
||||
// NextChunk returns up to max bytes. An empty result means end of stream.
|
||||
NextChunk(max int) ([]byte, error)
|
||||
Close() error
|
||||
}
|
||||
|
||||
type sourceStreamReader struct {
|
||||
stream SourceStream
|
||||
buf []byte
|
||||
eof bool
|
||||
}
|
||||
|
||||
// FileDropPayloads collects the items of one outgoing transfer.
|
||||
type FileDropPayloads struct {
|
||||
items []filedrop.Payload
|
||||
}
|
||||
|
||||
// NewFileDropPayloads returns an empty payload list to fill before sending.
|
||||
func NewFileDropPayloads() *FileDropPayloads {
|
||||
return &FileDropPayloads{}
|
||||
}
|
||||
|
||||
// AddFile appends a file item backed by a platform-provided source.
|
||||
func (p *FileDropPayloads) AddFile(name string, size int64, contentType string, source FileSource) error {
|
||||
if name == "" {
|
||||
return errors.New("file name is required")
|
||||
}
|
||||
if source == nil {
|
||||
return fmt.Errorf("file %s has no source", name)
|
||||
}
|
||||
|
||||
p.items = append(p.items, filedrop.Payload{
|
||||
Meta: filedrop.FileMeta{
|
||||
Name: name,
|
||||
Size: size,
|
||||
ContentType: contentType,
|
||||
},
|
||||
Open: func(offset int64) (io.ReadCloser, error) {
|
||||
stream, err := source.Open(offset)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if stream == nil {
|
||||
return nil, fmt.Errorf("no stream for %s", name)
|
||||
}
|
||||
return &sourceStreamReader{stream: stream}, nil
|
||||
},
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// AddText appends an inline text item.
|
||||
func (p *FileDropPayloads) AddText(name, text string) error {
|
||||
if len(text) > filedrop.MaxInlineTextSize {
|
||||
return fmt.Errorf("text exceeds %d bytes", filedrop.MaxInlineTextSize)
|
||||
}
|
||||
if name == "" {
|
||||
name = "text"
|
||||
}
|
||||
p.items = append(p.items, filedrop.TextPayload(name, text))
|
||||
return nil
|
||||
}
|
||||
|
||||
// Length returns the number of items.
|
||||
func (p *FileDropPayloads) Length() int {
|
||||
return len(p.items)
|
||||
}
|
||||
|
||||
func (r *sourceStreamReader) Read(p []byte) (int, error) {
|
||||
if len(p) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
for len(r.buf) == 0 {
|
||||
if r.eof {
|
||||
return 0, io.EOF
|
||||
}
|
||||
chunk, err := r.stream.NextChunk(len(p))
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if len(chunk) == 0 {
|
||||
r.eof = true
|
||||
return 0, io.EOF
|
||||
}
|
||||
r.buf = chunk
|
||||
}
|
||||
|
||||
n := copy(p, r.buf)
|
||||
r.buf = r.buf[n:]
|
||||
return n, nil
|
||||
}
|
||||
|
||||
func (r *sourceStreamReader) Close() error {
|
||||
return r.stream.Close()
|
||||
}
|
||||
261
client/android/filedrop_test.go
Normal file
261
client/android/filedrop_test.go
Normal file
@@ -0,0 +1,261 @@
|
||||
//go:build android
|
||||
|
||||
package android
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/filedrop"
|
||||
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
||||
)
|
||||
|
||||
type stubStream struct {
|
||||
reader io.Reader
|
||||
closed bool
|
||||
// chunk caps what one call returns, so the reader's buffering is exercised
|
||||
// rather than every read landing in a single hop.
|
||||
chunk int
|
||||
}
|
||||
|
||||
type stubSource struct {
|
||||
content string
|
||||
offsets []int64
|
||||
chunk int
|
||||
}
|
||||
|
||||
func (s *stubStream) NextChunk(max int) ([]byte, error) {
|
||||
if s.chunk > 0 && s.chunk < max {
|
||||
max = s.chunk
|
||||
}
|
||||
buf := make([]byte, max)
|
||||
|
||||
n, err := s.reader.Read(buf)
|
||||
if errors.Is(err, io.EOF) || n == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return buf[:n], nil
|
||||
}
|
||||
|
||||
func (s *stubStream) Close() error {
|
||||
s.closed = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *stubSource) Open(offset int64) (SourceStream, error) {
|
||||
s.offsets = append(s.offsets, offset)
|
||||
return &stubStream{reader: strings.NewReader(s.content[offset:]), chunk: s.chunk}, nil
|
||||
}
|
||||
|
||||
func TestPayloadSourceReassemblesChunks(t *testing.T) {
|
||||
for name, chunk := range map[string]int{
|
||||
"one hop": 0,
|
||||
"three bytes": 3,
|
||||
"one byte": 1,
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
source := &stubSource{content: "hello world", chunk: chunk}
|
||||
|
||||
payloads := NewFileDropPayloads()
|
||||
if err := payloads.AddFile("greeting.txt", 11, "text/plain", source); err != nil {
|
||||
t.Fatalf("AddFile: %v", err)
|
||||
}
|
||||
if payloads.Length() != 1 {
|
||||
t.Fatalf("expected 1 payload, got %d", payloads.Length())
|
||||
}
|
||||
|
||||
stream, err := payloads.items[0].Open(0)
|
||||
if err != nil {
|
||||
t.Fatalf("Open: %v", err)
|
||||
}
|
||||
got, err := io.ReadAll(stream)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadAll: %v", err)
|
||||
}
|
||||
if string(got) != "hello world" {
|
||||
t.Fatalf("got %q, want %q", got, "hello world")
|
||||
}
|
||||
if err := stream.Close(); err != nil {
|
||||
t.Fatalf("Close: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPayloadSourceHonoursOffset(t *testing.T) {
|
||||
source := &stubSource{content: "hello world"}
|
||||
|
||||
payloads := NewFileDropPayloads()
|
||||
if err := payloads.AddFile("greeting.txt", 11, "", source); err != nil {
|
||||
t.Fatalf("AddFile: %v", err)
|
||||
}
|
||||
|
||||
stream, err := payloads.items[0].Open(6)
|
||||
if err != nil {
|
||||
t.Fatalf("Open: %v", err)
|
||||
}
|
||||
defer stream.Close()
|
||||
|
||||
got, err := io.ReadAll(stream)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadAll: %v", err)
|
||||
}
|
||||
if string(got) != "world" {
|
||||
t.Fatalf("got %q, want %q", got, "world")
|
||||
}
|
||||
if len(source.offsets) != 1 || source.offsets[0] != 6 {
|
||||
t.Fatalf("expected one open at offset 6, got %v", source.offsets)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPayloadRejectsMissingSourceAndOversizedText(t *testing.T) {
|
||||
payloads := NewFileDropPayloads()
|
||||
|
||||
if err := payloads.AddFile("no-source.bin", 1, "", nil); err == nil {
|
||||
t.Fatal("expected an error for a file without a source")
|
||||
}
|
||||
if err := payloads.AddFile("", 1, "", &stubSource{}); err == nil {
|
||||
t.Fatal("expected an error for an empty file name")
|
||||
}
|
||||
if err := payloads.AddText("big", strings.Repeat("x", filedrop.MaxInlineTextSize+1)); err == nil {
|
||||
t.Fatal("expected an error for oversized text")
|
||||
}
|
||||
if payloads.Length() != 0 {
|
||||
t.Fatalf("expected no payloads, got %d", payloads.Length())
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileDropPersistsSettingsPerProfile(t *testing.T) {
|
||||
configDir := t.TempDir()
|
||||
writeTestProfile(t, configDir, "aaaaaaaabbbbbbbbccccccccdddddddd")
|
||||
writeTestProfile(t, configDir, "11111111222222223333333344444444")
|
||||
|
||||
first, err := NewFileDrop(configDir, "aaaaaaaabbbbbbbbccccccccdddddddd")
|
||||
if err != nil {
|
||||
t.Fatalf("NewFileDrop: %v", err)
|
||||
}
|
||||
defer first.Close()
|
||||
|
||||
if err := first.SetMode(FileDropModeAutoAccept); err != nil {
|
||||
t.Fatalf("SetMode: %v", err)
|
||||
}
|
||||
if err := first.SetPeerRule("peer-key", FileDropRuleBlock); err != nil {
|
||||
t.Fatalf("SetPeerRule: %v", err)
|
||||
}
|
||||
|
||||
second, err := NewFileDrop(configDir, "11111111222222223333333344444444")
|
||||
if err != nil {
|
||||
t.Fatalf("NewFileDrop: %v", err)
|
||||
}
|
||||
defer second.Close()
|
||||
|
||||
if got := second.Mode(); got != FileDropModeAsk {
|
||||
t.Fatalf("second profile mode = %d, want the default %d", got, FileDropModeAsk)
|
||||
}
|
||||
if got := second.PeerRule("peer-key"); got != FileDropRuleDefault {
|
||||
t.Fatalf("second profile rule = %d, want %d", got, FileDropRuleDefault)
|
||||
}
|
||||
|
||||
reopened, err := NewFileDrop(configDir, "aaaaaaaabbbbbbbbccccccccdddddddd")
|
||||
if err != nil {
|
||||
t.Fatalf("NewFileDrop: %v", err)
|
||||
}
|
||||
defer reopened.Close()
|
||||
|
||||
if got := reopened.Mode(); got != FileDropModeAutoAccept {
|
||||
t.Fatalf("reopened mode = %d, want %d", got, FileDropModeAutoAccept)
|
||||
}
|
||||
if got := reopened.PeerRule("peer-key"); got != FileDropRuleBlock {
|
||||
t.Fatalf("reopened rule = %d, want %d", got, FileDropRuleBlock)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileDropSeedsDefaultDestination(t *testing.T) {
|
||||
configDir := t.TempDir()
|
||||
writeTestProfile(t, configDir, "aaaaaaaabbbbbbbbccccccccdddddddd")
|
||||
|
||||
fd, err := NewFileDrop(configDir, "aaaaaaaabbbbbbbbccccccccdddddddd")
|
||||
if err != nil {
|
||||
t.Fatalf("NewFileDrop: %v", err)
|
||||
}
|
||||
defer fd.Close()
|
||||
|
||||
if fd.DestinationDir() != "" {
|
||||
t.Fatalf("expected no destination before seeding, got %q", fd.DestinationDir())
|
||||
}
|
||||
|
||||
ensureFileDropDestination(fd)
|
||||
|
||||
want := filepath.Join(configDir, filedropDataSubdir, "aaaaaaaabbbbbbbbccccccccdddddddd", "incoming")
|
||||
if got := fd.DestinationDir(); got != want {
|
||||
t.Fatalf("destination = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileDropSendWithoutTunnelFails(t *testing.T) {
|
||||
configDir := t.TempDir()
|
||||
writeTestProfile(t, configDir, "aaaaaaaabbbbbbbbccccccccdddddddd")
|
||||
|
||||
fd, err := NewFileDrop(configDir, "aaaaaaaabbbbbbbbccccccccdddddddd")
|
||||
if err != nil {
|
||||
t.Fatalf("NewFileDrop: %v", err)
|
||||
}
|
||||
defer fd.Close()
|
||||
|
||||
payloads := NewFileDropPayloads()
|
||||
if err := payloads.AddText("note", "hi"); err != nil {
|
||||
t.Fatalf("AddText: %v", err)
|
||||
}
|
||||
|
||||
if _, err := fd.Send("peer-key", "peer", "100.64.0.2", payloads); !errors.Is(err, filedrop.ErrNotConnected) {
|
||||
t.Fatalf("Send error = %v, want %v", err, filedrop.ErrNotConnected)
|
||||
}
|
||||
if _, err := fd.Send("peer-key", "peer", "100.64.0.2", NewFileDropPayloads()); err == nil {
|
||||
t.Fatal("expected an error when there is nothing to send")
|
||||
}
|
||||
if _, err := fd.Send("peer-key", "peer", "not-an-ip", payloads); err == nil {
|
||||
t.Fatal("expected an error for an unparseable peer address")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfileLocationForSplitsConfigPath(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
|
||||
dir, id, err := profileLocationFor(filepath.Join(root, defaultConfigFilename))
|
||||
if err != nil {
|
||||
t.Fatalf("default profile: %v", err)
|
||||
}
|
||||
if dir != root || id != profilemanager.DefaultProfileName {
|
||||
t.Fatalf("default profile = (%q, %q), want (%q, %q)", dir, id, root, profilemanager.DefaultProfileName)
|
||||
}
|
||||
|
||||
named := filepath.Join(root, profilesSubdir, "aaaaaaaabbbbbbbbccccccccdddddddd.json")
|
||||
dir, id, err = profileLocationFor(named)
|
||||
if err != nil {
|
||||
t.Fatalf("named profile: %v", err)
|
||||
}
|
||||
if dir != root || id != "aaaaaaaabbbbbbbbccccccccdddddddd" {
|
||||
t.Fatalf("named profile = (%q, %q), want (%q, %q)", dir, id, root, "aaaaaaaabbbbbbbbccccccccdddddddd")
|
||||
}
|
||||
|
||||
for _, path := range []string{"", filepath.Join(root, "stray.json"), filepath.Join(root, profilesSubdir, "not-an-id!.json")} {
|
||||
if _, _, err := profileLocationFor(path); err == nil {
|
||||
t.Fatalf("expected an error for %q", path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func writeTestProfile(t *testing.T, configDir, id string) {
|
||||
t.Helper()
|
||||
|
||||
pm := NewProfileManager(configDir)
|
||||
if _, err := pm.serviceMgr.ProfilePrefs(profilemanager.ID(id), androidUsername); err != nil {
|
||||
t.Fatalf("resolve prefs for %s: %v", id, err)
|
||||
}
|
||||
}
|
||||
163
client/android/filedrop_transfer.go
Normal file
163
client/android/filedrop_transfer.go
Normal file
@@ -0,0 +1,163 @@
|
||||
//go:build android
|
||||
|
||||
package android
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/filedrop"
|
||||
)
|
||||
|
||||
// The file drop receiving modes exported via gomobile.
|
||||
const (
|
||||
FileDropModeOff = int(filedrop.ModeOff)
|
||||
FileDropModeAsk = int(filedrop.ModeAsk)
|
||||
FileDropModeAutoAccept = int(filedrop.ModeAutoAccept)
|
||||
)
|
||||
|
||||
// The per-sender rules exported via gomobile.
|
||||
const (
|
||||
FileDropRuleDefault = int(filedrop.SenderRuleDefault)
|
||||
FileDropRuleAlwaysAccept = int(filedrop.SenderRuleAlwaysAccept)
|
||||
FileDropRuleBlock = int(filedrop.SenderRuleBlock)
|
||||
)
|
||||
|
||||
// The transfer states exported via gomobile.
|
||||
const (
|
||||
FileDropStatePending = int(filedrop.StatePending)
|
||||
FileDropStateTransferring = int(filedrop.StateTransferring)
|
||||
FileDropStateCompleted = int(filedrop.StateCompleted)
|
||||
FileDropStateDeclined = int(filedrop.StateDeclined)
|
||||
FileDropStateExpired = int(filedrop.StateExpired)
|
||||
FileDropStateCancelled = int(filedrop.StateCancelled)
|
||||
FileDropStateFailed = int(filedrop.StateFailed)
|
||||
)
|
||||
|
||||
// The failure reasons exported via gomobile.
|
||||
const (
|
||||
FileDropReasonNone = int(filedrop.ReasonNone)
|
||||
FileDropReasonUnreachable = int(filedrop.ReasonUnreachable)
|
||||
)
|
||||
|
||||
// The event kinds delivered to a FileDropListener.
|
||||
const (
|
||||
FileDropEventOffer = int(filedrop.EventOffer)
|
||||
FileDropEventCompleted = int(filedrop.EventCompleted)
|
||||
FileDropEventFailed = int(filedrop.EventFailed)
|
||||
FileDropEventWithdrawn = int(filedrop.EventWithdrawn)
|
||||
)
|
||||
|
||||
// FileDropListener receives transfer events. Calls arrive on background
|
||||
// goroutines, so implementations must post to the UI thread themselves.
|
||||
type FileDropListener interface {
|
||||
OnFileDropEvent(kind int, transfer *FileDropTransfer)
|
||||
}
|
||||
|
||||
// FileDropFile is one item of a transfer.
|
||||
type FileDropFile struct {
|
||||
Name string
|
||||
Size int64
|
||||
ContentType string
|
||||
IsText bool
|
||||
Text string
|
||||
}
|
||||
|
||||
// FileDropTransfer is one history entry.
|
||||
type FileDropTransfer struct {
|
||||
ID string
|
||||
Outgoing bool
|
||||
PeerKey string
|
||||
PeerName string
|
||||
State int
|
||||
Transferred int64
|
||||
TotalSize int64
|
||||
// Unix milliseconds, so the platform layer can render the time in the
|
||||
// user's own locale and zone rather than parsing a preformatted string.
|
||||
CreatedAtMillis int64
|
||||
UpdatedAtMillis int64
|
||||
// IsText marks a transfer that is a single inline snippet rather than
|
||||
// files, so the UI can drop the size and offer a copy action instead.
|
||||
IsText bool
|
||||
Error string
|
||||
Reason int
|
||||
|
||||
files []*FileDropFile
|
||||
deliveredPaths []string
|
||||
}
|
||||
|
||||
// FileDropTransferArray wraps transfers for gomobile compatibility.
|
||||
type FileDropTransferArray struct {
|
||||
items []*FileDropTransfer
|
||||
}
|
||||
|
||||
// FileCount returns the number of items in the transfer.
|
||||
func (t *FileDropTransfer) FileCount() int {
|
||||
return len(t.files)
|
||||
}
|
||||
|
||||
// GetFile returns the item at index i, or nil when out of range.
|
||||
func (t *FileDropTransfer) GetFile(i int) *FileDropFile {
|
||||
if i < 0 || i >= len(t.files) {
|
||||
return nil
|
||||
}
|
||||
return t.files[i]
|
||||
}
|
||||
|
||||
// DeliveredPaths returns the delivered file paths joined by newlines, so the
|
||||
// platform layer can move them into user-visible storage.
|
||||
func (t *FileDropTransfer) DeliveredPaths() string {
|
||||
return strings.Join(t.deliveredPaths, "\n")
|
||||
}
|
||||
|
||||
// Length returns the number of transfers.
|
||||
func (a *FileDropTransferArray) Length() int {
|
||||
return len(a.items)
|
||||
}
|
||||
|
||||
// Get returns the transfer at index i, or nil when out of range.
|
||||
func (a *FileDropTransferArray) Get(i int) *FileDropTransfer {
|
||||
if i < 0 || i >= len(a.items) {
|
||||
return nil
|
||||
}
|
||||
return a.items[i]
|
||||
}
|
||||
|
||||
func toFileDropTransfer(t filedrop.Transfer) *FileDropTransfer {
|
||||
files := make([]*FileDropFile, 0, len(t.Files))
|
||||
for _, f := range t.Files {
|
||||
files = append(files, &FileDropFile{
|
||||
Name: f.Name,
|
||||
Size: f.Size,
|
||||
ContentType: f.ContentType,
|
||||
IsText: f.Kind == filedrop.KindText,
|
||||
Text: f.Text,
|
||||
})
|
||||
}
|
||||
|
||||
return &FileDropTransfer{
|
||||
ID: string(t.ID),
|
||||
Outgoing: t.Direction == filedrop.DirectionSent,
|
||||
PeerKey: string(t.PeerKey),
|
||||
PeerName: t.PeerName,
|
||||
State: int(t.State),
|
||||
Transferred: t.Transferred,
|
||||
TotalSize: t.TotalSize,
|
||||
CreatedAtMillis: unixMillis(t.CreatedAt),
|
||||
UpdatedAtMillis: unixMillis(t.UpdatedAt),
|
||||
IsText: len(t.Files) == 1 && t.Files[0].Kind == filedrop.KindText,
|
||||
Error: t.Error,
|
||||
Reason: int(t.Reason),
|
||||
files: files,
|
||||
deliveredPaths: t.DeliveredPaths,
|
||||
}
|
||||
}
|
||||
|
||||
// unixMillis renders a timestamp for the platform layer, mapping the zero time
|
||||
// to 0 so it reads as "unknown" rather than as 1970.
|
||||
func unixMillis(t time.Time) int64 {
|
||||
if t.IsZero() {
|
||||
return 0
|
||||
}
|
||||
return t.UnixMilli()
|
||||
}
|
||||
@@ -4,6 +4,8 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/auth"
|
||||
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
||||
"github.com/netbirdio/netbird/client/system"
|
||||
@@ -36,12 +38,20 @@ type Auth struct {
|
||||
}
|
||||
|
||||
// NewAuth instantiate Auth struct and validate the management URL
|
||||
//
|
||||
// The configuration at cfgPath is reused when one is already there, and only created when it is
|
||||
// not. Building a fresh in-memory config unconditionally gives the client a new WireGuard key on
|
||||
// every call: the peer registers under that key, the key is written out, and any peer registered by
|
||||
// an earlier call is orphaned on the server. It also breaks a client that enrols and then runs from
|
||||
// the persisted config, because the identity it registered is not the one it runs with — the
|
||||
// management stream rejects it with "no peer auth method provided".
|
||||
func NewAuth(cfgPath string, mgmURL string) (*Auth, error) {
|
||||
inputCfg := profilemanager.ConfigInput{
|
||||
ConfigPath: cfgPath,
|
||||
ManagementURL: mgmURL,
|
||||
}
|
||||
|
||||
cfg, err := profilemanager.CreateInMemoryConfig(inputCfg)
|
||||
cfg, err := profilemanager.UpdateOrCreateConfig(inputCfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -53,11 +63,14 @@ func NewAuth(cfgPath string, mgmURL string) (*Auth, error) {
|
||||
}, nil
|
||||
}
|
||||
|
||||
// NewAuthWithConfig instantiate Auth based on existing config
|
||||
func NewAuthWithConfig(ctx context.Context, config *profilemanager.Config) *Auth {
|
||||
// NewAuthWithConfig instantiate Auth based on existing config. cfgPath is the
|
||||
// file the config was loaded from; it identifies the profile whose account email
|
||||
// backs the login_hint.
|
||||
func NewAuthWithConfig(ctx context.Context, config *profilemanager.Config, cfgPath string) *Auth {
|
||||
return &Auth{
|
||||
ctx: ctx,
|
||||
config: config,
|
||||
ctx: ctx,
|
||||
config: config,
|
||||
cfgPath: cfgPath,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -150,12 +163,14 @@ func (a *Auth) login(urlOpener URLOpener, isAndroidTV bool) error {
|
||||
}
|
||||
|
||||
jwtToken := ""
|
||||
email := ""
|
||||
if needsLogin {
|
||||
tokenInfo, err := a.foregroundGetTokenInfo(authClient, urlOpener, isAndroidTV)
|
||||
if err != nil {
|
||||
return fmt.Errorf("interactive sso login failed: %v", err)
|
||||
}
|
||||
jwtToken = tokenInfo.GetTokenToUse()
|
||||
email = tokenInfo.Email
|
||||
}
|
||||
|
||||
err, _ = authClient.Login(a.ctx, "", jwtToken)
|
||||
@@ -163,27 +178,62 @@ func (a *Auth) login(urlOpener URLOpener, isAndroidTV bool) error {
|
||||
return fmt.Errorf("login failed: %v", err)
|
||||
}
|
||||
|
||||
// Stored after Login, not before: a rejected token must not leave a hint
|
||||
// pointing at an account that cannot be used.
|
||||
if email != "" && a.cfgPath != "" {
|
||||
if err := writeProfileEmail(a.cfgPath, email); err != nil {
|
||||
log.Warnf("failed to store profile account email: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
go urlOpener.OnLoginSuccess()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *Auth) foregroundGetTokenInfo(authClient *auth.Auth, urlOpener URLOpener, isAndroidTV bool) (*auth.TokenInfo, error) {
|
||||
oAuthFlow, err := authClient.GetOAuthFlow(a.ctx, isAndroidTV)
|
||||
oAuthFlow, err := authClient.GetOAuthFlow(a.ctx, isAndroidTV, profileLoginHint(a.cfgPath))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get OAuth flow: %v", err)
|
||||
}
|
||||
|
||||
flowInfo, err := oAuthFlow.RequestAuthInfo(context.TODO())
|
||||
return runOAuthFlow(a.ctx, oAuthFlow, urlOpener, nil)
|
||||
}
|
||||
|
||||
// profileLoginHint returns the stored account email for the profile at cfgPath.
|
||||
// An empty hint is deliberate, not a fallback: a fresh profile leaves the
|
||||
// choice to the IdP. Switching accounts is done by switching or removing
|
||||
// profiles, not by logging out — logout keeps the email.
|
||||
func profileLoginHint(cfgPath string) string {
|
||||
if cfgPath == "" {
|
||||
return ""
|
||||
}
|
||||
return readProfileEmail(cfgPath)
|
||||
}
|
||||
|
||||
// runOAuthFlow drives an already acquired OAuth flow to a token: requests the
|
||||
// flow info, presents the verification URL through the opener and waits for
|
||||
// the browser round-trip. Open is called synchronously — it is what marks the
|
||||
// surface as opened on the client side, and a fast token's OnLoginSuccess is
|
||||
// a no-op until it has, so the dismissal would be dropped rather than
|
||||
// delayed. Openers must therefore not block: they post their UI work and
|
||||
// return. onWaiting, when set, runs after the URL is shown, right before the
|
||||
// blocking wait.
|
||||
func runOAuthFlow(ctx context.Context, flow auth.OAuthFlow, urlOpener URLOpener, onWaiting func()) (*auth.TokenInfo, error) {
|
||||
flowInfo, err := flow.RequestAuthInfo(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("getting a request OAuth flow info failed: %v", err)
|
||||
return nil, fmt.Errorf("request auth info: %w", err)
|
||||
}
|
||||
|
||||
go urlOpener.Open(flowInfo.VerificationURIComplete, flowInfo.UserCode)
|
||||
urlOpener.Open(flowInfo.VerificationURIComplete, flowInfo.UserCode)
|
||||
|
||||
tokenInfo, err := oAuthFlow.WaitToken(a.ctx, flowInfo)
|
||||
if onWaiting != nil {
|
||||
onWaiting()
|
||||
}
|
||||
|
||||
tokenInfo, err := flow.WaitToken(ctx, flowInfo)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("waiting for browser login failed: %v", err)
|
||||
return nil, fmt.Errorf("wait for token: %w", err)
|
||||
}
|
||||
|
||||
return &tokenInfo, nil
|
||||
|
||||
51
client/android/login_test.go
Normal file
51
client/android/login_test.go
Normal file
@@ -0,0 +1,51 @@
|
||||
package android
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// NewAuth must reuse the configuration already at cfgPath rather than building a fresh one.
|
||||
//
|
||||
// Creating a new in-memory config on every call gives the client a new WireGuard private key each
|
||||
// time. The peer registers under that key and the key is written out, so a peer registered by an
|
||||
// earlier call is orphaned on the server — a client that enrols twice leaves two entries and owns
|
||||
// neither. It also breaks enrol-then-run: RunWithoutLogin reloads the configuration from disk, so
|
||||
// the identity that registered is not the identity that runs, and the management stream rejects it
|
||||
// with "no peer auth method provided, please use a setup key or interactive SSO login".
|
||||
func TestNewAuth_ReusesPersistedIdentity(t *testing.T) {
|
||||
cfgPath := filepath.Join(t.TempDir(), "config.json")
|
||||
|
||||
first, err := NewAuth(cfgPath, "https://api.example.com:443")
|
||||
if err != nil {
|
||||
t.Fatalf("first NewAuth: %v", err)
|
||||
}
|
||||
if first.config.PrivateKey == "" {
|
||||
t.Fatal("first NewAuth produced no private key")
|
||||
}
|
||||
|
||||
second, err := NewAuth(cfgPath, "https://api.example.com:443")
|
||||
if err != nil {
|
||||
t.Fatalf("second NewAuth: %v", err)
|
||||
}
|
||||
|
||||
if second.config.PrivateKey != first.config.PrivateKey {
|
||||
t.Errorf("private key changed between calls: a second enrolment would orphan the peer registered by the first")
|
||||
}
|
||||
}
|
||||
|
||||
// A missing configuration is still created, so a first enrolment works unchanged.
|
||||
func TestNewAuth_CreatesConfigWhenAbsent(t *testing.T) {
|
||||
cfgPath := filepath.Join(t.TempDir(), "config.json")
|
||||
|
||||
auth, err := NewAuth(cfgPath, "https://api.example.com:443")
|
||||
if err != nil {
|
||||
t.Fatalf("NewAuth: %v", err)
|
||||
}
|
||||
if auth.config == nil || auth.config.PrivateKey == "" {
|
||||
t.Fatal("NewAuth did not create a usable configuration")
|
||||
}
|
||||
if auth.cfgPath != cfgPath {
|
||||
t.Errorf("cfgPath = %q, want %q", auth.cfgPath, cfgPath)
|
||||
}
|
||||
}
|
||||
@@ -13,18 +13,18 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
// Android-specific config filename (different from desktop default.json)
|
||||
defaultConfigFilename = "netbird.cfg"
|
||||
// Subdirectory for non-default profiles (must match Java Preferences.java)
|
||||
profilesSubdir = "profiles"
|
||||
// Android uses a single user context per app (non-empty username required by ServiceManager)
|
||||
androidUsername = "android"
|
||||
)
|
||||
|
||||
// Profile represents a profile for gomobile
|
||||
type Profile struct {
|
||||
ID string
|
||||
Name string
|
||||
ID string
|
||||
Name string
|
||||
// Email is the account this profile last logged in with, "" if it never
|
||||
// completed an SSO login. Kept across logouts; cleared when the profile is
|
||||
// removed. See profile_state.go.
|
||||
Email string
|
||||
IsActive bool
|
||||
}
|
||||
|
||||
@@ -101,6 +101,7 @@ func (pm *ProfileManager) ListProfiles() (*ProfileArray, error) {
|
||||
profiles = append(profiles, &Profile{
|
||||
ID: p.ID.String(),
|
||||
Name: p.Name,
|
||||
Email: pm.profileEmail(p.ID.String()),
|
||||
IsActive: p.IsActive,
|
||||
})
|
||||
}
|
||||
@@ -123,7 +124,22 @@ func (pm *ProfileManager) GetActiveProfile() (*Profile, error) {
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to resolve active profile %q: %w", activeState.ID, err)
|
||||
}
|
||||
return &Profile{ID: prof.ID.String(), Name: prof.Name, IsActive: true}, nil
|
||||
return &Profile{
|
||||
ID: prof.ID.String(),
|
||||
Name: prof.Name,
|
||||
Email: pm.profileEmail(prof.ID.String()),
|
||||
IsActive: true,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// profileEmail returns the account email recorded for a profile. Display-only, so
|
||||
// an unresolvable path degrades to "" rather than an error.
|
||||
func (pm *ProfileManager) profileEmail(id string) string {
|
||||
configPath, err := pm.getProfileConfigPath(id)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return readProfileEmail(configPath)
|
||||
}
|
||||
|
||||
// SwitchProfile switches to a different profile
|
||||
@@ -185,6 +201,9 @@ func (pm *ProfileManager) LogoutProfile(id string) error {
|
||||
return fmt.Errorf("failed to save config: %w", err)
|
||||
}
|
||||
|
||||
// The stored account email is kept on purpose, matching the desktop and CLI
|
||||
// logout semantics: the next login passes it as the login_hint so the IdP
|
||||
// preselects the account. Removing the profile is what deletes it.
|
||||
log.Infof("logged out from profile: %s", id)
|
||||
return nil
|
||||
}
|
||||
@@ -204,11 +223,24 @@ func (pm *ProfileManager) RenameProfile(id string, newName string) error {
|
||||
|
||||
// RemoveProfile deletes a profile
|
||||
func (pm *ProfileManager) RemoveProfile(id string) error {
|
||||
configPath, err := pm.getProfileConfigPath(id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Use ServiceManager (removes profile from profiles/ directory)
|
||||
if err := pm.serviceMgr.RemoveProfile(profilemanager.ID(id), androidUsername); err != nil {
|
||||
return fmt.Errorf("failed to remove profile: %w", err)
|
||||
}
|
||||
|
||||
// The account file is this package's, not the ServiceManager's, so it must
|
||||
// go here. The default profile has a fixed filename, so a recreated one
|
||||
// would otherwise inherit the deleted profile's email as its login_hint.
|
||||
// Not fatal: the profile itself is gone.
|
||||
if err := removeProfileEmail(configPath); err != nil {
|
||||
log.Warnf("failed to remove stored account email for profile %s: %v", id, err)
|
||||
}
|
||||
|
||||
log.Infof("removed profile: %s", id)
|
||||
return nil
|
||||
}
|
||||
|
||||
38
client/android/profile_prefs.go
Normal file
38
client/android/profile_prefs.go
Normal file
@@ -0,0 +1,38 @@
|
||||
//go:build android
|
||||
|
||||
package android
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
||||
)
|
||||
|
||||
type prefsStore interface {
|
||||
Get(namespace string, v any) (bool, error)
|
||||
Put(namespace string, v any) error
|
||||
}
|
||||
|
||||
type profilePrefs struct {
|
||||
prefs *profilemanager.Prefs
|
||||
}
|
||||
|
||||
func newProfilePrefs(configDir, profileID string) (*profilePrefs, error) {
|
||||
if configDir == "" || profileID == "" {
|
||||
return nil, fmt.Errorf("profile prefs require a config dir and profile ID")
|
||||
}
|
||||
pm := NewProfileManager(configDir)
|
||||
prefs, err := pm.serviceMgr.ProfilePrefs(profilemanager.ID(profileID), androidUsername)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("resolve profile prefs: %w", err)
|
||||
}
|
||||
return &profilePrefs{prefs: prefs}, nil
|
||||
}
|
||||
|
||||
func (p *profilePrefs) Get(namespace string, v any) (bool, error) {
|
||||
return p.prefs.Get(namespace, v)
|
||||
}
|
||||
|
||||
func (p *profilePrefs) Put(namespace string, v any) error {
|
||||
return p.prefs.Put(namespace, v)
|
||||
}
|
||||
135
client/android/profile_state.go
Normal file
135
client/android/profile_state.go
Normal file
@@ -0,0 +1,135 @@
|
||||
package android
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
||||
"github.com/netbirdio/netbird/util"
|
||||
)
|
||||
|
||||
const (
|
||||
// Android-specific config filename (different from desktop default.json)
|
||||
defaultConfigFilename = "netbird.cfg"
|
||||
// Subdirectory for non-default profiles (must match Java Preferences.java)
|
||||
profilesSubdir = "profiles"
|
||||
// profileAccountSuffix names the file holding the profile's account email.
|
||||
// Deliberately not ".state.json", which desktop uses for the same data:
|
||||
// there the email and the engine's state manager live in different
|
||||
// directories, but on Android both resolve under files/, so sharing the name
|
||||
// would have the two overwrite each other — the state manager rewrites the
|
||||
// whole file from its own keys (see statemanager.Manager.PersistState), and
|
||||
// this package's writer does the same in reverse.
|
||||
profileAccountSuffix = ".account.json"
|
||||
)
|
||||
|
||||
// profileAccountPathFor derives the account file path from a profile's config
|
||||
// path: netbird.cfg -> netbird.account.json, <id>.json -> <id>.account.json.
|
||||
//
|
||||
// Deriving from the config path rather than resolving the active profile keeps
|
||||
// the write on the profile the login actually ran for: Auth.login runs in a
|
||||
// goroutine, so the active profile can change under a flow already in flight.
|
||||
func profileAccountPathFor(configPath string) (string, error) {
|
||||
if configPath == "" {
|
||||
return "", fmt.Errorf("empty config path")
|
||||
}
|
||||
|
||||
base := filepath.Base(configPath)
|
||||
stem := strings.TrimSuffix(base, filepath.Ext(base))
|
||||
if stem == "" || stem == "." {
|
||||
return "", fmt.Errorf("config path %q has no filename stem", configPath)
|
||||
}
|
||||
|
||||
return filepath.Join(filepath.Dir(configPath), stem+profileAccountSuffix), nil
|
||||
}
|
||||
|
||||
// profileLocationFor splits a profile's config path back into the config dir and
|
||||
// the profile ID: <dir>/netbird.cfg is the default profile, while
|
||||
// <dir>/profiles/<id>.json is a named one.
|
||||
func profileLocationFor(configPath string) (string, string, error) {
|
||||
if configPath == "" {
|
||||
return "", "", fmt.Errorf("empty config path")
|
||||
}
|
||||
|
||||
base := filepath.Base(configPath)
|
||||
dir := filepath.Dir(configPath)
|
||||
|
||||
if base == defaultConfigFilename {
|
||||
return dir, profilemanager.DefaultProfileName, nil
|
||||
}
|
||||
|
||||
if filepath.Base(dir) != profilesSubdir {
|
||||
return "", "", fmt.Errorf("config path %q is outside the profiles directory", configPath)
|
||||
}
|
||||
|
||||
id := strings.TrimSuffix(base, filepath.Ext(base))
|
||||
if !profilemanager.IsValidProfileFilenameStem(profilemanager.ID(id)) {
|
||||
return "", "", fmt.Errorf("config path %q has no valid profile ID", configPath)
|
||||
}
|
||||
|
||||
return filepath.Dir(dir), id, nil
|
||||
}
|
||||
|
||||
// readProfileEmail returns the account email stored for the profile whose config
|
||||
// lives at configPath. A missing or unreadable file yields "", which leaves the
|
||||
// account choice to the IdP.
|
||||
func readProfileEmail(configPath string) string {
|
||||
accountPath, err := profileAccountPathFor(configPath)
|
||||
if err != nil {
|
||||
log.Debugf("no profile account path for login hint: %v", err)
|
||||
return ""
|
||||
}
|
||||
|
||||
var state profilemanager.ProfileState
|
||||
if _, err := util.ReadJson(accountPath, &state); err != nil {
|
||||
if !os.IsNotExist(err) {
|
||||
log.Debugf("failed to read profile account for login hint: %v", err)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
return state.Email
|
||||
}
|
||||
|
||||
// writeProfileEmail records the account email for the profile whose config lives
|
||||
// at configPath, so later logins can pass it as an OIDC login_hint. An empty
|
||||
// email is ignored rather than blanking what is already stored.
|
||||
func writeProfileEmail(configPath string, email string) error {
|
||||
if email == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
accountPath, err := profileAccountPathFor(configPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("resolve profile account path: %w", err)
|
||||
}
|
||||
|
||||
state := profilemanager.ProfileState{Email: email}
|
||||
if err := util.WriteJsonWithRestrictedPermission(context.Background(), accountPath, state); err != nil {
|
||||
return fmt.Errorf("write profile account: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// removeProfileEmail drops the stored account email. Called on profile removal,
|
||||
// not on logout: a logged-out profile keeps its email so the next login passes
|
||||
// it as the login_hint, matching the desktop and CLI semantics. Mirrors the
|
||||
// desktop UI's RemoveProfileState call.
|
||||
func removeProfileEmail(configPath string) error {
|
||||
accountPath, err := profileAccountPathFor(configPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("resolve profile account path: %w", err)
|
||||
}
|
||||
|
||||
if err := os.Remove(accountPath); err != nil && !os.IsNotExist(err) {
|
||||
return fmt.Errorf("remove profile account: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
161
client/android/profile_state_test.go
Normal file
161
client/android/profile_state_test.go
Normal file
@@ -0,0 +1,161 @@
|
||||
package android
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestProfileAccountPathFor(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
configPath string
|
||||
want string
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "default profile",
|
||||
configPath: "/data/data/io.netbird.client/files/netbird.cfg",
|
||||
want: filepath.FromSlash("/data/data/io.netbird.client/files/netbird.account.json"),
|
||||
},
|
||||
{
|
||||
name: "id profile",
|
||||
configPath: "/data/data/io.netbird.client/files/profiles/4c5f5c8198c3989cffb5b5394f5a7ae0.json",
|
||||
want: filepath.FromSlash("/data/data/io.netbird.client/files/profiles/4c5f5c8198c3989cffb5b5394f5a7ae0.account.json"),
|
||||
},
|
||||
{
|
||||
name: "legacy name-keyed profile is handled the same way",
|
||||
configPath: "/data/data/io.netbird.client/files/profiles/work.json",
|
||||
want: filepath.FromSlash("/data/data/io.netbird.client/files/profiles/work.account.json"),
|
||||
},
|
||||
{
|
||||
name: "empty path is rejected",
|
||||
configPath: "",
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := profileAccountPathFor(tt.configPath)
|
||||
if tt.wantErr {
|
||||
if err == nil {
|
||||
t.Fatalf("expected an error, got path %q", got)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got != tt.want {
|
||||
t.Errorf("got %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfileAccountPathForDefaultDoesNotCollide(t *testing.T) {
|
||||
root := "/data/data/io.netbird.client/files"
|
||||
|
||||
defaultAccount, err := profileAccountPathFor(filepath.Join(root, defaultConfigFilename))
|
||||
if err != nil {
|
||||
t.Fatalf("default profile: %v", err)
|
||||
}
|
||||
|
||||
idAccount, err := profileAccountPathFor(filepath.Join(root, profilesSubdir, "abc123.json"))
|
||||
if err != nil {
|
||||
t.Fatalf("id profile: %v", err)
|
||||
}
|
||||
|
||||
if defaultAccount == idAccount {
|
||||
t.Fatalf("default and id profile share an account file: %q", defaultAccount)
|
||||
}
|
||||
}
|
||||
|
||||
// The account file must never land on the engine state file: on Android both
|
||||
// resolve under files/, and the state manager rewrites the whole file from its
|
||||
// own keys, so sharing a path would have the two overwrite each other. The
|
||||
// expected names here mirror ProfileManager.GetStateFilePath.
|
||||
func TestProfileAccountPathAvoidsEngineStateFile(t *testing.T) {
|
||||
root := "/data/data/io.netbird.client/files"
|
||||
|
||||
cases := []struct {
|
||||
configPath string
|
||||
engineState string
|
||||
}{
|
||||
{
|
||||
configPath: filepath.Join(root, defaultConfigFilename),
|
||||
engineState: filepath.Join(root, "state.json"),
|
||||
},
|
||||
{
|
||||
configPath: filepath.Join(root, profilesSubdir, "abc123.json"),
|
||||
engineState: filepath.Join(root, profilesSubdir, "abc123.state.json"),
|
||||
},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
account, err := profileAccountPathFor(c.configPath)
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", c.configPath, err)
|
||||
}
|
||||
if account == c.engineState {
|
||||
t.Errorf("account file collides with the engine state file: %q", account)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteThenReadProfileEmail(t *testing.T) {
|
||||
configPath := filepath.Join(t.TempDir(), "profiles", "abc123.json")
|
||||
if err := ensureDirFor(t, configPath); err != nil {
|
||||
t.Fatalf("prepare dir: %v", err)
|
||||
}
|
||||
|
||||
if got := readProfileEmail(configPath); got != "" {
|
||||
t.Errorf("expected no email before a login, got %q", got)
|
||||
}
|
||||
|
||||
const email = "user@example.com"
|
||||
if err := writeProfileEmail(configPath, email); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
|
||||
if got := readProfileEmail(configPath); got != email {
|
||||
t.Errorf("got %q, want %q", got, email)
|
||||
}
|
||||
|
||||
if err := removeProfileEmail(configPath); err != nil {
|
||||
t.Fatalf("remove: %v", err)
|
||||
}
|
||||
if got := readProfileEmail(configPath); got != "" {
|
||||
t.Errorf("expected no email after removal, got %q", got)
|
||||
}
|
||||
|
||||
// Removal may run on a never-logged-in profile, so a second remove must pass.
|
||||
if err := removeProfileEmail(configPath); err != nil {
|
||||
t.Fatalf("second remove should be a no-op: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteProfileEmailIgnoresEmpty(t *testing.T) {
|
||||
configPath := filepath.Join(t.TempDir(), "profiles", "abc123.json")
|
||||
if err := ensureDirFor(t, configPath); err != nil {
|
||||
t.Fatalf("prepare dir: %v", err)
|
||||
}
|
||||
|
||||
const email = "user@example.com"
|
||||
if err := writeProfileEmail(configPath, email); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
if err := writeProfileEmail(configPath, ""); err != nil {
|
||||
t.Fatalf("write empty: %v", err)
|
||||
}
|
||||
|
||||
if got := readProfileEmail(configPath); got != email {
|
||||
t.Errorf("empty write clobbered the stored email: got %q, want %q", got, email)
|
||||
}
|
||||
}
|
||||
|
||||
func ensureDirFor(t *testing.T, path string) error {
|
||||
t.Helper()
|
||||
return os.MkdirAll(filepath.Dir(path), 0o700)
|
||||
}
|
||||
@@ -278,7 +278,7 @@ func (c *Client) endExtend() {
|
||||
}
|
||||
|
||||
func (c *Client) extendAuthSession(ctx context.Context, urlOpener URLOpener, isAndroidTV bool) error {
|
||||
cfg, _, cc := c.stateSnapshot()
|
||||
cfg, cfgPath, cc := c.authSnapshot()
|
||||
if cfg == nil || cc == nil {
|
||||
return fmt.Errorf("engine is not running")
|
||||
}
|
||||
@@ -293,7 +293,10 @@ func (c *Client) extendAuthSession(ctx context.Context, urlOpener URLOpener, isA
|
||||
}
|
||||
defer authClient.Close()
|
||||
|
||||
a := &Auth{ctx: ctx, config: cfg}
|
||||
// Passing the config path makes the flow pick up the login_hint: an extend
|
||||
// renews the session of the account already signed in, so it must not stop to
|
||||
// offer a choice.
|
||||
a := NewAuthWithConfig(ctx, cfg, cfgPath)
|
||||
tokenInfo, err := a.foregroundGetTokenInfo(authClient, urlOpener, isAndroidTV)
|
||||
if err != nil {
|
||||
return fmt.Errorf("interactive sso login failed: %v", err)
|
||||
|
||||
649
client/android/ssh_client.go
Normal file
649
client/android/ssh_client.go
Normal file
@@ -0,0 +1,649 @@
|
||||
//go:build android
|
||||
|
||||
package android
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
gossh "golang.org/x/crypto/ssh"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal"
|
||||
"github.com/netbirdio/netbird/client/internal/auth"
|
||||
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
||||
nbssh "github.com/netbirdio/netbird/client/ssh"
|
||||
"github.com/netbirdio/netbird/client/ssh/detection"
|
||||
)
|
||||
|
||||
const (
|
||||
sshDialTimeout = 30 * time.Second
|
||||
sshDetectionTimeout = 5 * time.Second
|
||||
)
|
||||
|
||||
// PasswordRequiredMarker tells Java to prompt for a password and retry. It is
|
||||
// a string because gomobile flattens errors to their message, so a sentinel
|
||||
// value would not survive the binding.
|
||||
const PasswordRequiredMarker = "netbird-ssh-password-required"
|
||||
|
||||
// HostKeyUnknownMarker tells Java to show the fingerprint and, on confirmation,
|
||||
// retry with TrustHostKey set. The presented fingerprint is appended after the
|
||||
// marker so the prompt can display it and the retry can guard against a key
|
||||
// that changed between the two connects. Only regular (non-NetBird) servers
|
||||
// reach this: NetBird peers verify against the registry.
|
||||
const HostKeyUnknownMarker = "netbird-ssh-hostkey-unknown"
|
||||
|
||||
var (
|
||||
errPasswordRequired = errors.New(PasswordRequiredMarker)
|
||||
errClientClosed = errors.New("ssh client closed")
|
||||
)
|
||||
|
||||
// errHostKeyUnknown carries the presented fingerprint so Connect can build the
|
||||
// marker message the Java side parses.
|
||||
type errHostKeyUnknown struct {
|
||||
fingerprint string
|
||||
}
|
||||
|
||||
func (e *errHostKeyUnknown) Error() string {
|
||||
return HostKeyUnknownMarker + ":" + e.fingerprint
|
||||
}
|
||||
|
||||
// SSHTerminalListener receives SSH session events. It is implemented in Java.
|
||||
//
|
||||
// All callbacks are invoked from goroutines and may run concurrently with each
|
||||
// other; the implementation must be safe to call from any thread.
|
||||
type SSHTerminalListener interface {
|
||||
OnConnected()
|
||||
OnData(data []byte)
|
||||
OnClose(reason string)
|
||||
OnError(message string)
|
||||
}
|
||||
|
||||
// SSHClient is a NetBird-aware SSH client exposed to Java via gomobile.
|
||||
//
|
||||
// It dials through the running NetBird tunnel and runs a standard SSH session
|
||||
// on top with PTY enabled. Host-key verification uses the NetBird-provided
|
||||
// peer SSH host keys, identical to the desktop client.
|
||||
type SSHClient struct {
|
||||
nb *Client
|
||||
mu sync.Mutex
|
||||
listener SSHTerminalListener
|
||||
urlOpener URLOpener
|
||||
|
||||
sshClient *gossh.Client
|
||||
session *gossh.Session
|
||||
stdin io.WriteCloser
|
||||
closed bool
|
||||
|
||||
// gen identifies the current connection attempt. Connect and Close bump it,
|
||||
// so an in-flight dial or a reader left over from a previous connection
|
||||
// finds itself stale and stays silent instead of publishing OnConnected or
|
||||
// OnClose for a connection the caller already abandoned.
|
||||
gen uint64
|
||||
dialCancel context.CancelFunc
|
||||
|
||||
// knownHostsConfigDir and knownHostsProfile locate the TOFU store for
|
||||
// regular SSH servers in the profile's preferences. Java supplies them,
|
||||
// since an overlay IP is a different host under a different profile. Empty
|
||||
// until set: without them a regular server cannot be verified and Connect
|
||||
// refuses one.
|
||||
knownHostsConfigDir string
|
||||
knownHostsProfile string
|
||||
// trustHostKey carries the fingerprint the user confirmed on a previous
|
||||
// attempt, so the retry accepts exactly that key and persists it.
|
||||
trustHostKey string
|
||||
}
|
||||
|
||||
// NewSSHClient creates a new SSH client bound to the running NetBird Client.
|
||||
func NewSSHClient(c *Client) *SSHClient {
|
||||
return &SSHClient{nb: c}
|
||||
}
|
||||
|
||||
// SetListener registers the Java listener. Must be called before Connect to
|
||||
// receive any events.
|
||||
func (s *SSHClient) SetListener(l SSHTerminalListener) {
|
||||
s.mu.Lock()
|
||||
s.listener = l
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
// SetURLOpener registers the Java URL opener used to display the device-code
|
||||
// authorization page in a Custom Tabs window when the target peer requires
|
||||
// JWT authentication. Must be set before Connect to be effective.
|
||||
func (s *SSHClient) SetURLOpener(opener URLOpener) {
|
||||
s.mu.Lock()
|
||||
s.urlOpener = opener
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
// SetKnownHostsStore points the TOFU host-key store at a profile's preferences.
|
||||
// Must be set before connecting to a regular SSH server; without it such a
|
||||
// server cannot be verified and Connect refuses one.
|
||||
func (s *SSHClient) SetKnownHostsStore(configDir, profileID string) {
|
||||
s.mu.Lock()
|
||||
s.knownHostsConfigDir = configDir
|
||||
s.knownHostsProfile = profileID
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
// TrustHostKey records the fingerprint the user confirmed for a regular server,
|
||||
// so the next Connect accepts that exact key and adds it to the known-hosts
|
||||
// store. Passing a fingerprint that no longer matches makes the connect fail
|
||||
// rather than trust a key that changed since the prompt.
|
||||
func (s *SSHClient) TrustHostKey(fingerprint string) {
|
||||
s.mu.Lock()
|
||||
s.trustHostKey = fingerprint
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
// Connect dials the SSH server through the NetBird tunnel and performs the
|
||||
// SSH handshake. It auto-detects the server type via SSH banner inspection
|
||||
// and selects the appropriate authentication path:
|
||||
//
|
||||
// - NetBird-SSH server requiring JWT: launches the OAuth 2.0 device-code
|
||||
// flow, opens the verification URL through the registered URLOpener, and
|
||||
// uses the resulting token as the SSH password. Host-key verification
|
||||
// uses the NetBird peer registry.
|
||||
// - NetBird-SSH server without JWT: authenticates with the NetBird SSH
|
||||
// private key. Host-key verification uses the NetBird peer registry.
|
||||
// - Regular SSH server (e.g. OpenSSH): authenticates with the NetBird key
|
||||
// first (so a user-installed NetBird public key works), then falls back
|
||||
// to the supplied password if non-empty. Host-key verification is
|
||||
// trust-on-first-use against the per-profile known-hosts store.
|
||||
//
|
||||
// The password parameter is only consulted for regular SSH servers.
|
||||
func (s *SSHClient) Connect(host string, port int, user, password string) error {
|
||||
if port < 1 || port > 65535 {
|
||||
return fmt.Errorf("invalid port: %d", port)
|
||||
}
|
||||
|
||||
cfg, cfgPath, cc := s.nb.authSnapshot()
|
||||
if cc == nil {
|
||||
return errors.New("netbird client not running")
|
||||
}
|
||||
if cfg == nil {
|
||||
return errors.New("netbird config not loaded")
|
||||
}
|
||||
engine := cc.Engine()
|
||||
if engine == nil {
|
||||
return errors.New("netbird engine not available")
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
s.gen++
|
||||
gen := s.gen
|
||||
s.mu.Unlock()
|
||||
|
||||
serverType := detectServerType(host, port)
|
||||
log.Debugf("SSH server type: %s", serverType)
|
||||
|
||||
authMethods, hostKeyCallback, err := s.buildAuth(cfg, cfgPath, engine, serverType, password)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
clientConfig := &gossh.ClientConfig{
|
||||
User: user,
|
||||
Auth: authMethods,
|
||||
HostKeyCallback: hostKeyCallback,
|
||||
Timeout: sshDialTimeout,
|
||||
}
|
||||
err = s.dialAndHandshake(gen, host, port, clientConfig)
|
||||
|
||||
// An unknown host key is a prompt, not a failure: return the marker intact
|
||||
// (rootCause would unwrap it) so Java can show the fingerprint and retry.
|
||||
var unknownHost *errHostKeyUnknown
|
||||
if errors.As(err, &unknownHost) {
|
||||
return errors.New(unknownHost.Error())
|
||||
}
|
||||
|
||||
// A regular server may still accept a password, so let the caller ask for
|
||||
// one instead of failing. NetBird servers never use a password, so a
|
||||
// failure there is genuine.
|
||||
if err != nil && serverType != detection.ServerTypeNetBirdJWT &&
|
||||
serverType != detection.ServerTypeNetBirdNoJWT && isAuthFailure(err) &&
|
||||
passwordCouldHelp(err, password != "") {
|
||||
return errPasswordRequired
|
||||
}
|
||||
if err != nil {
|
||||
return rootCause(err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// StartSession requests a PTY and starts an interactive shell. Output from
|
||||
// the session is forwarded to the listener via OnData.
|
||||
func (s *SSHClient) StartSession(cols, rows int) error {
|
||||
err := s.startSession(cols, rows)
|
||||
if err != nil {
|
||||
log.Infof("SSH: start session failed: %v", err)
|
||||
return rootCause(err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Write sends data to the SSH session stdin.
|
||||
func (s *SSHClient) Write(data []byte) error {
|
||||
s.mu.Lock()
|
||||
stdin := s.stdin
|
||||
s.mu.Unlock()
|
||||
if stdin == nil {
|
||||
return errors.New("ssh session not started")
|
||||
}
|
||||
if _, err := stdin.Write(data); err != nil {
|
||||
return fmt.Errorf("write stdin: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Resize updates the PTY window size.
|
||||
func (s *SSHClient) Resize(cols, rows int) error {
|
||||
s.mu.Lock()
|
||||
session := s.session
|
||||
s.mu.Unlock()
|
||||
if session == nil {
|
||||
return errors.New("ssh session not started")
|
||||
}
|
||||
return session.WindowChange(rows, cols)
|
||||
}
|
||||
|
||||
// Reset makes a closed client usable for another Connect: Close leaves the
|
||||
// one-shot guard set, and clearing it lets the same client back a reconnect.
|
||||
func (s *SSHClient) Reset() {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.closed = false
|
||||
}
|
||||
|
||||
// Close terminates the SSH session and underlying connection. Safe to call
|
||||
// multiple times.
|
||||
func (s *SSHClient) Close() error {
|
||||
s.mu.Lock()
|
||||
s.gen++
|
||||
if s.dialCancel != nil {
|
||||
s.dialCancel()
|
||||
s.dialCancel = nil
|
||||
}
|
||||
sshClient := s.sshClient
|
||||
session := s.session
|
||||
stdin := s.stdin
|
||||
s.sshClient = nil
|
||||
s.session = nil
|
||||
s.stdin = nil
|
||||
notify := !s.closed
|
||||
s.closed = true
|
||||
listener := s.listener
|
||||
s.mu.Unlock()
|
||||
|
||||
if stdin != nil {
|
||||
if err := stdin.Close(); err != nil {
|
||||
log.Debugf("ssh: stdin close: %v", err)
|
||||
}
|
||||
}
|
||||
if session != nil {
|
||||
if err := session.Close(); err != nil && !errors.Is(err, io.EOF) {
|
||||
log.Debugf("ssh: session close: %v", err)
|
||||
}
|
||||
}
|
||||
var firstErr error
|
||||
if sshClient != nil {
|
||||
if err := sshClient.Close(); err != nil {
|
||||
firstErr = err
|
||||
}
|
||||
}
|
||||
if notify && listener != nil {
|
||||
listener.OnClose("closed by client")
|
||||
}
|
||||
return firstErr
|
||||
}
|
||||
|
||||
func (s *SSHClient) startSession(cols, rows int) error {
|
||||
log.Debugf("SSH: starting session %dx%d", cols, rows)
|
||||
s.mu.Lock()
|
||||
sshClient := s.sshClient
|
||||
gen := s.gen
|
||||
s.mu.Unlock()
|
||||
|
||||
if sshClient == nil {
|
||||
return errors.New("ssh client not connected")
|
||||
}
|
||||
|
||||
pty, err := nbssh.StartPTYSession(sshClient, cols, rows)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
if gen != s.gen {
|
||||
s.mu.Unlock()
|
||||
closeQuiet(pty.Session, "stale session")
|
||||
return errClientClosed
|
||||
}
|
||||
s.session = pty.Session
|
||||
s.stdin = pty.Stdin
|
||||
s.mu.Unlock()
|
||||
|
||||
readerDone := make(chan string, 2)
|
||||
go func() { readerDone <- s.readLoop(pty.Stdout, "stdout") }()
|
||||
go func() { readerDone <- s.readLoop(pty.Stderr, "stderr") }()
|
||||
go func() {
|
||||
reason := <-readerDone
|
||||
if second := <-readerDone; reason == "" {
|
||||
reason = second
|
||||
}
|
||||
s.notifyClose(gen, reason)
|
||||
}()
|
||||
log.Debug("SSH: session started, shell running")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SSHClient) buildAuth(cfg *profilemanager.Config, cfgPath string, engine *internal.Engine,
|
||||
serverType detection.ServerType, password string) ([]gossh.AuthMethod, gossh.HostKeyCallback, error) {
|
||||
|
||||
switch serverType {
|
||||
case detection.ServerTypeNetBirdJWT:
|
||||
token, err := s.requestJWTToken(cfg, cfgPath)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("jwt: %w", err)
|
||||
}
|
||||
auths := []gossh.AuthMethod{gossh.Password(token)}
|
||||
return auths, nbssh.CreateHostKeyCallback(nbssh.PeerKeyLookup(engine.GetPeerSSHKey)), nil
|
||||
|
||||
case detection.ServerTypeNetBirdNoJWT:
|
||||
if cfg.SSHKey == "" {
|
||||
return nil, nil, errors.New("no NetBird SSH key available")
|
||||
}
|
||||
signer, err := gossh.ParsePrivateKey([]byte(cfg.SSHKey))
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("parse netbird ssh key: %w", err)
|
||||
}
|
||||
auths := []gossh.AuthMethod{gossh.PublicKeys(signer)}
|
||||
return auths, nbssh.CreateHostKeyCallback(nbssh.PeerKeyLookup(engine.GetPeerSSHKey)), nil
|
||||
|
||||
case detection.ServerTypeRegular:
|
||||
var auths []gossh.AuthMethod
|
||||
if cfg.SSHKey != "" {
|
||||
if signer, err := gossh.ParsePrivateKey([]byte(cfg.SSHKey)); err == nil {
|
||||
auths = append(auths, gossh.PublicKeys(signer))
|
||||
} else {
|
||||
log.Debugf("ssh: parse netbird key for regular auth: %v", err)
|
||||
}
|
||||
}
|
||||
if password != "" {
|
||||
pw := password
|
||||
auths = append(auths, gossh.Password(pw))
|
||||
auths = append(auths, gossh.KeyboardInteractive(func(_, _ string, questions []string, _ []bool) ([]string, error) {
|
||||
answers := make([]string, len(questions))
|
||||
for i := range questions {
|
||||
answers[i] = pw
|
||||
}
|
||||
return answers, nil
|
||||
}))
|
||||
}
|
||||
if len(auths) == 0 {
|
||||
// Nothing to offer at all: ask for a password rather than failing,
|
||||
// so the caller can retry once the user supplies one.
|
||||
return nil, nil, errPasswordRequired
|
||||
}
|
||||
callback, err := s.tofuHostKeyCallback()
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return auths, callback, nil
|
||||
|
||||
default:
|
||||
return nil, nil, fmt.Errorf("unsupported SSH server type: %v", serverType)
|
||||
}
|
||||
}
|
||||
|
||||
// tofuHostKeyCallback verifies a regular server's host key against the
|
||||
// per-profile known-hosts store. An unknown host returns errHostKeyUnknown so
|
||||
// Java can show the fingerprint and, once confirmed, retry with the key
|
||||
// trusted; a changed key is rejected outright, as OpenSSH does. When the user
|
||||
// has confirmed a fingerprint, the callback accepts exactly that key and
|
||||
// appends it to the store.
|
||||
func (s *SSHClient) tofuHostKeyCallback() (gossh.HostKeyCallback, error) {
|
||||
s.mu.Lock()
|
||||
configDir := s.knownHostsConfigDir
|
||||
profileID := s.knownHostsProfile
|
||||
trusted := s.trustHostKey
|
||||
s.mu.Unlock()
|
||||
|
||||
if configDir == "" || profileID == "" {
|
||||
return nil, errors.New("no known-hosts store configured for regular SSH")
|
||||
}
|
||||
|
||||
store, err := openKnownHostsStore(configDir, profileID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load known-hosts store: %w", err)
|
||||
}
|
||||
|
||||
return func(hostname string, remote net.Addr, key gossh.PublicKey) error {
|
||||
verdict, err := store.verify(hostname, remote, key)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if verdict == hostKeyMatched {
|
||||
return nil
|
||||
}
|
||||
if verdict == hostKeyChanged {
|
||||
return fmt.Errorf("SSH host key changed for %s (possible attack)", hostname)
|
||||
}
|
||||
|
||||
fingerprint := gossh.FingerprintSHA256(key)
|
||||
if trusted == "" {
|
||||
return &errHostKeyUnknown{fingerprint: fingerprint}
|
||||
}
|
||||
if trusted != fingerprint {
|
||||
return fmt.Errorf("SSH host key changed since it was confirmed for %s", hostname)
|
||||
}
|
||||
if err := store.append(hostname, remote, key); err != nil {
|
||||
return fmt.Errorf("persist trusted host key: %w", err)
|
||||
}
|
||||
// The confirmation is spent: now that the key is stored, a later
|
||||
// reconnect must verify against the file, not re-accept this fingerprint.
|
||||
s.mu.Lock()
|
||||
s.trustHostKey = ""
|
||||
s.mu.Unlock()
|
||||
return nil
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *SSHClient) requestJWTToken(cfg *profilemanager.Config, cfgPath string) (string, error) {
|
||||
s.mu.Lock()
|
||||
urlOpener := s.urlOpener
|
||||
s.mu.Unlock()
|
||||
if urlOpener == nil {
|
||||
return "", errors.New("URL opener not configured for JWT auth")
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
flow, err := auth.NewOAuthFlow(ctx, cfg, false, true, profileLoginHint(cfgPath))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("create oauth flow: %w", err)
|
||||
}
|
||||
|
||||
// The status callback covers the browser round-trip, which would
|
||||
// otherwise leave the terminal blank.
|
||||
tokenInfo, err := runOAuthFlow(ctx, flow, urlOpener, func() {
|
||||
s.notifyStatus("Waiting for browser authentication...")
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
token := tokenInfo.GetTokenToUse()
|
||||
if token == "" {
|
||||
return "", errors.New("empty token returned by IdP")
|
||||
}
|
||||
|
||||
// Tells the client the browser round-trip is over so it can dismiss the
|
||||
// surface it opened, the same way the login and session-extend flows do.
|
||||
// Without it the Custom Tab stays in front of the terminal even though the
|
||||
// token has already been collected.
|
||||
urlOpener.OnLoginSuccess()
|
||||
|
||||
return token, nil
|
||||
}
|
||||
|
||||
func (s *SSHClient) dialAndHandshake(gen uint64, host string, port int, clientConfig *gossh.ClientConfig) error {
|
||||
addr := net.JoinHostPort(host, strconv.Itoa(port))
|
||||
ctx, cancel := context.WithTimeout(context.Background(), sshDialTimeout)
|
||||
defer cancel()
|
||||
|
||||
s.mu.Lock()
|
||||
if gen != s.gen {
|
||||
s.mu.Unlock()
|
||||
return errClientClosed
|
||||
}
|
||||
s.dialCancel = cancel
|
||||
s.mu.Unlock()
|
||||
|
||||
var dialer net.Dialer
|
||||
conn, err := dialer.DialContext(ctx, "tcp", addr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("dial %s: %w", addr, err)
|
||||
}
|
||||
|
||||
client, err := nbssh.Handshake(ctx, conn, addr, clientConfig)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
if gen != s.gen {
|
||||
s.mu.Unlock()
|
||||
closeQuiet(client, "stale ssh client")
|
||||
return errClientClosed
|
||||
}
|
||||
s.sshClient = client
|
||||
listener := s.listener
|
||||
s.mu.Unlock()
|
||||
|
||||
if listener != nil {
|
||||
listener.OnConnected()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SSHClient) readLoop(r io.Reader, name string) string {
|
||||
buf := make([]byte, 4096)
|
||||
for {
|
||||
n, err := r.Read(buf)
|
||||
if n > 0 {
|
||||
s.mu.Lock()
|
||||
listener := s.listener
|
||||
s.mu.Unlock()
|
||||
if listener != nil {
|
||||
chunk := make([]byte, n)
|
||||
copy(chunk, buf[:n])
|
||||
listener.OnData(chunk)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
// EOF is a normal shell exit, so report it without a reason.
|
||||
if errors.Is(err, io.EOF) {
|
||||
return ""
|
||||
}
|
||||
log.Debugf("ssh %s read: %v", name, err)
|
||||
return rootCause(err).Error()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// notifyStatus writes a progress line to the terminal through the normal
|
||||
// output path, so long steps are visible while nothing else is arriving.
|
||||
func (s *SSHClient) notifyStatus(text string) {
|
||||
s.mu.Lock()
|
||||
listener := s.listener
|
||||
s.mu.Unlock()
|
||||
if listener != nil {
|
||||
listener.OnData([]byte("\r\n\x1b[33m" + text + "\x1b[0m\r\n"))
|
||||
}
|
||||
}
|
||||
|
||||
func (s *SSHClient) notifyClose(gen uint64, reason string) {
|
||||
s.mu.Lock()
|
||||
if gen != s.gen || s.closed {
|
||||
s.mu.Unlock()
|
||||
return
|
||||
}
|
||||
s.closed = true
|
||||
listener := s.listener
|
||||
s.mu.Unlock()
|
||||
if listener != nil {
|
||||
listener.OnClose(reason)
|
||||
}
|
||||
}
|
||||
|
||||
func closeQuiet(c io.Closer, label string) {
|
||||
if c == nil {
|
||||
return
|
||||
}
|
||||
if err := c.Close(); err != nil && !errors.Is(err, io.EOF) {
|
||||
log.Debugf("ssh: close %s: %v", label, err)
|
||||
}
|
||||
}
|
||||
|
||||
func detectServerType(host string, port int) detection.ServerType {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), sshDetectionTimeout)
|
||||
defer cancel()
|
||||
|
||||
dialer := &net.Dialer{}
|
||||
serverType, err := detection.DetectSSHServerType(ctx, dialer, host, port)
|
||||
if err != nil {
|
||||
log.Debugf("ssh: server detection failed: %v (assuming regular SSH)", err)
|
||||
return detection.ServerTypeRegular
|
||||
}
|
||||
return serverType
|
||||
}
|
||||
|
||||
// rootCause returns the innermost error of a %w chain, so the terminal shows
|
||||
// "i/o timeout" rather than every layer that added context on the way up.
|
||||
func rootCause(err error) error {
|
||||
for {
|
||||
// A joined error has no single root, so keep it as-is.
|
||||
if _, ok := err.(interface{ Unwrap() []error }); ok {
|
||||
return err
|
||||
}
|
||||
next := errors.Unwrap(err)
|
||||
if next == nil {
|
||||
return err
|
||||
}
|
||||
err = next
|
||||
}
|
||||
}
|
||||
|
||||
// isAuthFailure distinguishes credential rejection from dial, timeout and
|
||||
// host-key errors, which retrying with a password would not fix.
|
||||
func isAuthFailure(err error) bool {
|
||||
if errors.Is(err, errPasswordRequired) {
|
||||
return true
|
||||
}
|
||||
var partial *gossh.PartialSuccessError
|
||||
if errors.As(err, &partial) {
|
||||
return true
|
||||
}
|
||||
return strings.Contains(err.Error(), "unable to authenticate")
|
||||
}
|
||||
|
||||
// passwordCouldHelp reports whether prompting for a password again can change
|
||||
// the outcome. gossh lists a method under "attempted methods" only when the
|
||||
// server offered it, so a supplied password that was never attempted means the
|
||||
// server does not accept passwords and the real error should surface instead.
|
||||
func passwordCouldHelp(err error, passwordOffered bool) bool {
|
||||
if !passwordOffered {
|
||||
return true
|
||||
}
|
||||
msg := err.Error()
|
||||
return strings.Contains(msg, "password") || strings.Contains(msg, "keyboard-interactive")
|
||||
}
|
||||
168
client/android/ssh_known_hosts.go
Normal file
168
client/android/ssh_known_hosts.go
Normal file
@@ -0,0 +1,168 @@
|
||||
//go:build android
|
||||
|
||||
package android
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"net"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
gossh "golang.org/x/crypto/ssh"
|
||||
"golang.org/x/crypto/ssh/knownhosts"
|
||||
)
|
||||
|
||||
const knownHostsNamespace = "ssh"
|
||||
|
||||
const (
|
||||
hostKeyUnknown hostKeyVerdict = iota
|
||||
hostKeyMatched
|
||||
hostKeyChanged
|
||||
)
|
||||
|
||||
var knownHostsMu sync.Mutex
|
||||
|
||||
type hostKeyVerdict uint8
|
||||
|
||||
type knownHostsSection struct {
|
||||
KnownHosts []string `json:"knownHosts"`
|
||||
}
|
||||
|
||||
type knownHostsStore struct {
|
||||
prefs prefsStore
|
||||
}
|
||||
|
||||
// RemoveKnownHost deletes every known-hosts entry for host:port from the
|
||||
// profile's store, so a host trusted for a session that is being deleted does
|
||||
// not linger. Java calls this only once no session targets that host, so a
|
||||
// shared host stays trusted. A missing entry is not an error: the goal state
|
||||
// is "absent".
|
||||
func RemoveKnownHost(configDir, profileID, host string, port int) error {
|
||||
store, err := openKnownHostsStore(configDir, profileID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return store.removeHost(host, port)
|
||||
}
|
||||
|
||||
func openKnownHostsStore(configDir, profileID string) (*knownHostsStore, error) {
|
||||
prefs, err := newProfilePrefs(configDir, profileID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &knownHostsStore{prefs: prefs}, nil
|
||||
}
|
||||
|
||||
func (st *knownHostsStore) verify(hostname string, remote net.Addr, key gossh.PublicKey) (hostKeyVerdict, error) {
|
||||
lines, err := st.lines()
|
||||
if err != nil {
|
||||
return hostKeyUnknown, err
|
||||
}
|
||||
targets := knownHostsTargets(hostname, remote)
|
||||
|
||||
verdict := hostKeyUnknown
|
||||
for _, line := range lines {
|
||||
pubKey, ok := knownHostsLineKey(line, targets)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if pubKey.Type() == key.Type() && bytes.Equal(pubKey.Marshal(), key.Marshal()) {
|
||||
return hostKeyMatched, nil
|
||||
}
|
||||
verdict = hostKeyChanged
|
||||
}
|
||||
return verdict, nil
|
||||
}
|
||||
|
||||
func (st *knownHostsStore) append(hostname string, remote net.Addr, key gossh.PublicKey) error {
|
||||
line := knownhosts.Line(knownHostsTargets(hostname, remote), key)
|
||||
|
||||
knownHostsMu.Lock()
|
||||
defer knownHostsMu.Unlock()
|
||||
|
||||
lines, err := st.lines()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return st.prefs.Put(knownHostsNamespace, knownHostsSection{KnownHosts: append(lines, line)})
|
||||
}
|
||||
|
||||
func (st *knownHostsStore) removeHost(host string, port int) error {
|
||||
target := knownhosts.Normalize(net.JoinHostPort(host, strconv.Itoa(port)))
|
||||
|
||||
knownHostsMu.Lock()
|
||||
defer knownHostsMu.Unlock()
|
||||
|
||||
lines, err := st.lines()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
kept := make([]string, 0, len(lines))
|
||||
for _, line := range lines {
|
||||
if knownHostsLineMatches(line, target) {
|
||||
continue
|
||||
}
|
||||
kept = append(kept, line)
|
||||
}
|
||||
if len(kept) == len(lines) {
|
||||
return nil
|
||||
}
|
||||
return st.prefs.Put(knownHostsNamespace, knownHostsSection{KnownHosts: kept})
|
||||
}
|
||||
|
||||
func (st *knownHostsStore) lines() ([]string, error) {
|
||||
var section knownHostsSection
|
||||
if _, err := st.prefs.Get(knownHostsNamespace, §ion); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return section.KnownHosts, nil
|
||||
}
|
||||
|
||||
func knownHostsTargets(hostname string, remote net.Addr) []string {
|
||||
targets := []string{knownhosts.Normalize(hostname)}
|
||||
if remote != nil {
|
||||
if normalized := knownhosts.Normalize(remote.String()); normalized != targets[0] {
|
||||
targets = append(targets, normalized)
|
||||
}
|
||||
}
|
||||
return targets
|
||||
}
|
||||
|
||||
func knownHostsLineKey(line string, targets []string) (gossh.PublicKey, bool) {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if trimmed == "" || strings.HasPrefix(trimmed, "#") {
|
||||
return nil, false
|
||||
}
|
||||
_, hosts, pubKey, _, _, err := gossh.ParseKnownHosts([]byte(trimmed))
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
for _, host := range hosts {
|
||||
for _, target := range targets {
|
||||
if host == target {
|
||||
return pubKey, true
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// knownHostsLineMatches reports whether a known-hosts line's address list
|
||||
// contains the normalized target. Comment and blank lines never match.
|
||||
func knownHostsLineMatches(line, target string) bool {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if trimmed == "" || strings.HasPrefix(trimmed, "#") {
|
||||
return false
|
||||
}
|
||||
fields := strings.Fields(trimmed)
|
||||
if len(fields) == 0 {
|
||||
return false
|
||||
}
|
||||
for _, addr := range strings.Split(fields[0], ",") {
|
||||
if addr == target {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
104
client/android/ssh_sessions.go
Normal file
104
client/android/ssh_sessions.go
Normal file
@@ -0,0 +1,104 @@
|
||||
//go:build android
|
||||
|
||||
package android
|
||||
|
||||
const (
|
||||
sshSessionsNamespace = "ssh-sessions"
|
||||
maxStoredSSHSessions = 50
|
||||
)
|
||||
|
||||
type sshSessionRecord struct {
|
||||
ID string `json:"id"`
|
||||
Host string `json:"host"`
|
||||
Port int `json:"port"`
|
||||
User string `json:"user"`
|
||||
}
|
||||
|
||||
type sshSessionsSection struct {
|
||||
Sessions []sshSessionRecord `json:"sessions"`
|
||||
}
|
||||
|
||||
// SSHSessionEntry is one stored SSH session, without any credential.
|
||||
type SSHSessionEntry struct {
|
||||
ID string
|
||||
Host string
|
||||
Port int
|
||||
User string
|
||||
}
|
||||
|
||||
// SSHSessionArray wraps stored SSH sessions for gomobile compatibility.
|
||||
type SSHSessionArray struct {
|
||||
items []*SSHSessionEntry
|
||||
}
|
||||
|
||||
// NewSSHSessionArray creates an empty session array to fill via Add.
|
||||
func NewSSHSessionArray() *SSHSessionArray {
|
||||
return &SSHSessionArray{}
|
||||
}
|
||||
|
||||
// Add appends a session entry, oldest first.
|
||||
func (a *SSHSessionArray) Add(id, host string, port int, user string) {
|
||||
a.items = append(a.items, &SSHSessionEntry{ID: id, Host: host, Port: port, User: user})
|
||||
}
|
||||
|
||||
// Length returns the number of entries.
|
||||
func (a *SSHSessionArray) Length() int {
|
||||
return len(a.items)
|
||||
}
|
||||
|
||||
// Get returns the entry at index i, or nil when out of range.
|
||||
func (a *SSHSessionArray) Get(i int) *SSHSessionEntry {
|
||||
if i < 0 || i >= len(a.items) {
|
||||
return nil
|
||||
}
|
||||
return a.items[i]
|
||||
}
|
||||
|
||||
// SSHSessionStore reads and writes a profile's stored SSH sessions.
|
||||
type SSHSessionStore struct {
|
||||
prefs prefsStore
|
||||
}
|
||||
|
||||
// NewSSHSessionStore opens the session store of the given profile.
|
||||
func NewSSHSessionStore(configDir, profileID string) (*SSHSessionStore, error) {
|
||||
prefs, err := newProfilePrefs(configDir, profileID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &SSHSessionStore{prefs: prefs}, nil
|
||||
}
|
||||
|
||||
// Load returns the stored sessions, oldest first.
|
||||
func (s *SSHSessionStore) Load() (*SSHSessionArray, error) {
|
||||
var section sshSessionsSection
|
||||
if _, err := s.prefs.Get(sshSessionsNamespace, §ion); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
out := NewSSHSessionArray()
|
||||
for _, record := range section.Sessions {
|
||||
if record.ID == "" || record.Host == "" {
|
||||
continue
|
||||
}
|
||||
out.Add(record.ID, record.Host, record.Port, record.User)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Save replaces the stored sessions, keeping only the newest entries when the
|
||||
// list exceeds the storage cap.
|
||||
func (s *SSHSessionStore) Save(sessions *SSHSessionArray) error {
|
||||
var items []*SSHSessionEntry
|
||||
if sessions != nil {
|
||||
items = sessions.items
|
||||
}
|
||||
if len(items) > maxStoredSSHSessions {
|
||||
items = items[len(items)-maxStoredSSHSessions:]
|
||||
}
|
||||
|
||||
records := make([]sshSessionRecord, 0, len(items))
|
||||
for _, item := range items {
|
||||
records = append(records, sshSessionRecord{ID: item.ID, Host: item.Host, Port: item.Port, User: item.User})
|
||||
}
|
||||
return s.prefs.Put(sshSessionsNamespace, sshSessionsSection{Sessions: records})
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package anonymize
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"net"
|
||||
@@ -15,13 +16,88 @@ import (
|
||||
|
||||
const anonTLD = ".domain"
|
||||
|
||||
// Level selects how much the anonymizer redacts. Levels are ordered: a higher
|
||||
// level redacts strictly more. On the wire (protos, flags) levels travel as
|
||||
// their string form.
|
||||
type Level int
|
||||
|
||||
const (
|
||||
// LevelDefault anonymizes public IP addresses, IPv6 ULA, domains, and MAC
|
||||
// addresses. Internal IPv4 ranges (RFC 1918, CGNAT, link-local) are
|
||||
// preserved so support can reason about the real topology.
|
||||
LevelDefault Level = iota
|
||||
// LevelStrict additionally anonymizes internal IP ranges, peer names, and
|
||||
// WireGuard public keys.
|
||||
LevelStrict
|
||||
)
|
||||
|
||||
// LevelDefaultString and LevelStrictString are the wire forms of the levels,
|
||||
// for boundaries that pass levels as strings (flags, protos, mobile bindings).
|
||||
const (
|
||||
LevelDefaultString = "default"
|
||||
LevelStrictString = "strict"
|
||||
)
|
||||
|
||||
// ParseLevel maps s to a Level. Empty means LevelDefault; anything
|
||||
// unrecognized maps to LevelStrict so an unknown request never yields less
|
||||
// anonymization than intended.
|
||||
func ParseLevel(s string) Level {
|
||||
switch strings.ToLower(s) {
|
||||
case "", LevelDefaultString:
|
||||
return LevelDefault
|
||||
default:
|
||||
return LevelStrict
|
||||
}
|
||||
}
|
||||
|
||||
// String returns the wire form of the level: "default" or "strict".
|
||||
func (l Level) String() string {
|
||||
if l >= LevelStrict {
|
||||
return LevelStrictString
|
||||
}
|
||||
return LevelDefaultString
|
||||
}
|
||||
|
||||
// protectedDomains are NetBird-operated suffixes that stay recognizable in an
|
||||
// anonymized bundle. At LevelStrict the labels in front of them (the peer
|
||||
// name) are still replaced, except under netbird.io, which only hosts
|
||||
// NetBird infrastructure (api, signal, flow), never peer names.
|
||||
var protectedDomains = []string{"netbird.io", "netbird.selfhosted", "netbird.cloud", "netbird.stage"}
|
||||
|
||||
const infraDomain = "netbird.io"
|
||||
|
||||
var (
|
||||
macColonRegex = regexp.MustCompile(`\b[0-9a-fA-F]{2}(?::[0-9a-fA-F]{2}){5}\b`)
|
||||
macDashRegex = regexp.MustCompile(`\b[0-9a-fA-F]{2}(?:-[0-9a-fA-F]{2}){5}\b`)
|
||||
wgKeyRegex = regexp.MustCompile(`\b[A-Za-z0-9+/]{43}=`)
|
||||
)
|
||||
|
||||
type Anonymizer struct {
|
||||
ipAnonymizer map[netip.Addr]netip.Addr
|
||||
domainAnonymizer map[string]string
|
||||
currentAnonIPv4 netip.Addr
|
||||
currentAnonIPv6 netip.Addr
|
||||
startAnonIPv4 netip.Addr
|
||||
startAnonIPv6 netip.Addr
|
||||
// domainOrder caches the keys of domainAnonymizer sorted longest-first
|
||||
// for AnonymizeString; it is rebuilt when the map gains entries.
|
||||
domainOrder []string
|
||||
labelAnonymizer map[string]string
|
||||
labelAnonymized map[string]struct{}
|
||||
labelCounter uint32
|
||||
macAnonymizer map[string]string
|
||||
macCounter uint32
|
||||
wgKeyAnonymizer map[string]string
|
||||
wgKeyAnonymized map[string]struct{}
|
||||
currentAnonIPv4 netip.Addr
|
||||
currentAnonIPv6 netip.Addr
|
||||
startAnonIPv4 netip.Addr
|
||||
startAnonIPv6 netip.Addr
|
||||
|
||||
// LevelStrict also anonymizes internal ranges (RFC 1918, CGNAT,
|
||||
// link-local), replacing them from the dedicated internal pools below so
|
||||
// a reader can still tell an internal address from a public one.
|
||||
level Level
|
||||
currentAnonInternalIPv4 netip.Addr
|
||||
currentAnonInternalIPv6 netip.Addr
|
||||
startAnonInternalIPv4 netip.Addr
|
||||
startAnonInternalIPv6 netip.Addr
|
||||
|
||||
domainKeyRegex *regexp.Regexp
|
||||
}
|
||||
@@ -32,25 +108,50 @@ func DefaultAddresses() (netip.Addr, netip.Addr) {
|
||||
return netip.AddrFrom4([4]byte{198, 51, 100, 0}), netip.MustParseAddr("2001:db8:ffff::")
|
||||
}
|
||||
|
||||
// InternalAddresses returns the pool starts used in strict mode for internal
|
||||
// ranges. Both are reserved ranges that cannot collide with real addressing:
|
||||
// 198.18.0.0 (RFC 2544 benchmarking), 2001:db8:1:: (RFC 3849 documentation).
|
||||
func InternalAddresses() (netip.Addr, netip.Addr) {
|
||||
return netip.AddrFrom4([4]byte{198, 18, 0, 0}), netip.MustParseAddr("2001:db8:1::")
|
||||
}
|
||||
|
||||
func NewAnonymizer(startIPv4, startIPv6 netip.Addr) *Anonymizer {
|
||||
internalIPv4, internalIPv6 := InternalAddresses()
|
||||
return &Anonymizer{
|
||||
ipAnonymizer: map[netip.Addr]netip.Addr{},
|
||||
domainAnonymizer: map[string]string{},
|
||||
labelAnonymizer: map[string]string{},
|
||||
labelAnonymized: map[string]struct{}{},
|
||||
macAnonymizer: map[string]string{},
|
||||
wgKeyAnonymizer: map[string]string{},
|
||||
wgKeyAnonymized: map[string]struct{}{},
|
||||
currentAnonIPv4: startIPv4,
|
||||
currentAnonIPv6: startIPv6,
|
||||
startAnonIPv4: startIPv4,
|
||||
startAnonIPv6: startIPv6,
|
||||
|
||||
level: LevelDefault,
|
||||
currentAnonInternalIPv4: internalIPv4,
|
||||
currentAnonInternalIPv6: internalIPv6,
|
||||
startAnonInternalIPv4: internalIPv4,
|
||||
startAnonInternalIPv6: internalIPv6,
|
||||
|
||||
domainKeyRegex: regexp.MustCompile(`\bdomain=([^\s,:"]+)`),
|
||||
}
|
||||
}
|
||||
|
||||
// SetLevel selects the anonymization level. The zero value of a new
|
||||
// Anonymizer is LevelDefault.
|
||||
func (a *Anonymizer) SetLevel(level Level) {
|
||||
a.level = level
|
||||
}
|
||||
|
||||
func (a *Anonymizer) AnonymizeIP(ip netip.Addr) netip.Addr {
|
||||
// Normalize 4-in-6 addresses so ::ffff:192.168.1.1 classifies and maps
|
||||
// like 192.168.1.1.
|
||||
ip = ip.Unmap()
|
||||
|
||||
if ip.IsLoopback() ||
|
||||
ip.IsLinkLocalUnicast() ||
|
||||
ip.IsLinkLocalMulticast() ||
|
||||
ip.IsInterfaceLocalMulticast() ||
|
||||
(ip.Is4() && ip.IsPrivate()) ||
|
||||
ip.IsUnspecified() ||
|
||||
ip.IsMulticast() ||
|
||||
isWellKnown(ip) ||
|
||||
@@ -59,18 +160,100 @@ func (a *Anonymizer) AnonymizeIP(ip netip.Addr) netip.Addr {
|
||||
return ip
|
||||
}
|
||||
|
||||
if isInternal(ip) && a.level < LevelStrict {
|
||||
return ip
|
||||
}
|
||||
|
||||
if _, ok := a.ipAnonymizer[ip]; !ok {
|
||||
if ip.Is4() {
|
||||
a.ipAnonymizer[ip] = a.currentAnonIPv4
|
||||
a.currentAnonIPv4 = a.currentAnonIPv4.Next()
|
||||
} else {
|
||||
a.ipAnonymizer[ip] = a.currentAnonIPv6
|
||||
a.currentAnonIPv6 = a.currentAnonIPv6.Next()
|
||||
}
|
||||
a.ipAnonymizer[ip] = a.nextAnonIP(ip)
|
||||
}
|
||||
return a.ipAnonymizer[ip]
|
||||
}
|
||||
|
||||
func (a *Anonymizer) nextAnonIP(ip netip.Addr) netip.Addr {
|
||||
// At the strict level, internal addresses (including IPv6 ULA, matched
|
||||
// by IsPrivate) come from the internal pools so they remain recognizable
|
||||
// as internal without disclosing the real values.
|
||||
if a.level >= LevelStrict && (isInternal(ip) || ip.IsPrivate()) {
|
||||
if ip.Is4() {
|
||||
anon := a.currentAnonInternalIPv4
|
||||
a.currentAnonInternalIPv4 = a.currentAnonInternalIPv4.Next()
|
||||
return anon
|
||||
}
|
||||
anon := a.currentAnonInternalIPv6
|
||||
a.currentAnonInternalIPv6 = a.currentAnonInternalIPv6.Next()
|
||||
return anon
|
||||
}
|
||||
|
||||
if ip.Is4() {
|
||||
anon := a.currentAnonIPv4
|
||||
a.currentAnonIPv4 = a.currentAnonIPv4.Next()
|
||||
return anon
|
||||
}
|
||||
anon := a.currentAnonIPv6
|
||||
a.currentAnonIPv6 = a.currentAnonIPv6.Next()
|
||||
return anon
|
||||
}
|
||||
|
||||
// AnonymizeMAC replaces a MAC address with a consistent placeholder from the
|
||||
// locally administered range starting at 02:00:00:00:00:01, at every
|
||||
// anonymization level. Broadcast, multicast, all-zero, and already assigned
|
||||
// placeholder addresses are preserved. The colon and dash spellings of the
|
||||
// same address share one placeholder; the output keeps the input's separator.
|
||||
func (a *Anonymizer) AnonymizeMAC(mac string) string {
|
||||
hw, err := net.ParseMAC(mac)
|
||||
if err != nil || len(hw) != 6 {
|
||||
return mac
|
||||
}
|
||||
|
||||
if isWellKnownMAC(hw) || a.isAnonymizedMAC(hw) {
|
||||
return mac
|
||||
}
|
||||
|
||||
key := hw.String()
|
||||
anon, ok := a.macAnonymizer[key]
|
||||
if !ok {
|
||||
a.macCounter++
|
||||
anon = fmt.Sprintf("02:00:00:%02x:%02x:%02x", byte(a.macCounter>>16), byte(a.macCounter>>8), byte(a.macCounter))
|
||||
a.macAnonymizer[key] = anon
|
||||
}
|
||||
|
||||
if strings.Contains(mac, "-") {
|
||||
anon = strings.ReplaceAll(anon, ":", "-")
|
||||
}
|
||||
return anon
|
||||
}
|
||||
|
||||
// isAnonymizedMAC reports whether hw is a placeholder this anonymizer already
|
||||
// handed out, so a second pass over anonymized output leaves it unchanged.
|
||||
func (a *Anonymizer) isAnonymizedMAC(hw net.HardwareAddr) bool {
|
||||
if hw[0] != 0x02 || hw[1] != 0 || hw[2] != 0 {
|
||||
return false
|
||||
}
|
||||
value := uint32(hw[3])<<16 | uint32(hw[4])<<8 | uint32(hw[5])
|
||||
return value <= a.macCounter
|
||||
}
|
||||
|
||||
// AnonymizeWGKey replaces a WireGuard public key with a consistent random
|
||||
// placeholder of the same shape. Keys are only anonymized at LevelStrict;
|
||||
// placeholders already handed out pass through unchanged.
|
||||
func (a *Anonymizer) AnonymizeWGKey(key string) string {
|
||||
if a.level < LevelStrict || !looksLikeWGKey(key) {
|
||||
return key
|
||||
}
|
||||
if _, ok := a.wgKeyAnonymized[key]; ok {
|
||||
return key
|
||||
}
|
||||
|
||||
anon, ok := a.wgKeyAnonymizer[key]
|
||||
if !ok {
|
||||
anon = generateAnonymousKey()
|
||||
a.wgKeyAnonymizer[key] = anon
|
||||
a.wgKeyAnonymized[anon] = struct{}{}
|
||||
}
|
||||
return anon
|
||||
}
|
||||
|
||||
func (a *Anonymizer) AnonymizeUDPAddr(addr net.UDPAddr) net.UDPAddr {
|
||||
// Convert IP to netip.Addr
|
||||
ip, ok := netip.AddrFromSlice(addr.IP)
|
||||
@@ -89,12 +272,12 @@ func (a *Anonymizer) AnonymizeUDPAddr(addr net.UDPAddr) net.UDPAddr {
|
||||
|
||||
// isInAnonymizedRange checks if an IP is within the range of already assigned anonymized IPs
|
||||
func (a *Anonymizer) isInAnonymizedRange(ip netip.Addr) bool {
|
||||
if ip.Is4() && ip.Compare(a.startAnonIPv4) >= 0 && ip.Compare(a.currentAnonIPv4) <= 0 {
|
||||
return true
|
||||
} else if !ip.Is4() && ip.Compare(a.startAnonIPv6) >= 0 && ip.Compare(a.currentAnonIPv6) <= 0 {
|
||||
return true
|
||||
if ip.Is4() {
|
||||
return inPoolRange(ip, a.startAnonIPv4, a.currentAnonIPv4) ||
|
||||
inPoolRange(ip, a.startAnonInternalIPv4, a.currentAnonInternalIPv4)
|
||||
}
|
||||
return false
|
||||
return inPoolRange(ip, a.startAnonIPv6, a.currentAnonIPv6) ||
|
||||
inPoolRange(ip, a.startAnonInternalIPv6, a.currentAnonInternalIPv6)
|
||||
}
|
||||
|
||||
func (a *Anonymizer) AnonymizeIPString(ip string) string {
|
||||
@@ -118,14 +301,23 @@ func (a *Anonymizer) AnonymizeDomain(domain string) string {
|
||||
baseDomain = domain[:len(domain)-1]
|
||||
}
|
||||
|
||||
if strings.HasSuffix(baseDomain, "netbird.io") ||
|
||||
strings.HasSuffix(baseDomain, "netbird.selfhosted") ||
|
||||
strings.HasSuffix(baseDomain, "netbird.cloud") ||
|
||||
strings.HasSuffix(baseDomain, "netbird.stage") ||
|
||||
strings.HasSuffix(baseDomain, anonTLD) {
|
||||
if strings.HasSuffix(baseDomain, anonTLD) {
|
||||
return domain
|
||||
}
|
||||
|
||||
// A reverse zone names an address prefix, so it follows the address rules,
|
||||
// which also keeps its digit labels intact.
|
||||
if zone, ok := a.anonymizeReverseZone(baseDomain); ok {
|
||||
return withTrailingDot(zone, hasDot)
|
||||
}
|
||||
|
||||
if suffix := protectedSuffix(baseDomain); suffix != "" {
|
||||
if a.level < LevelStrict || baseDomain == suffix || suffix == infraDomain {
|
||||
return domain
|
||||
}
|
||||
return withTrailingDot(a.anonymizePeerName(baseDomain, suffix), hasDot)
|
||||
}
|
||||
|
||||
parts := strings.Split(baseDomain, ".")
|
||||
if len(parts) < 2 {
|
||||
return domain
|
||||
@@ -141,12 +333,53 @@ func (a *Anonymizer) AnonymizeDomain(domain string) string {
|
||||
}
|
||||
|
||||
result := strings.Replace(baseDomain, baseForLookup, anonymized, 1)
|
||||
if hasDot {
|
||||
result += "."
|
||||
if a.level >= LevelStrict && len(parts) > 2 {
|
||||
prefix := strings.TrimSuffix(baseDomain, "."+baseForLookup)
|
||||
result = a.anonymizeLabels(prefix, "host") + "." + anonymized
|
||||
// The full mapping feeds AnonymizeString so seeded FQDNs are caught
|
||||
// in log lines as a whole, labels included.
|
||||
a.domainAnonymizer[baseDomain] = result
|
||||
}
|
||||
return withTrailingDot(result, hasDot)
|
||||
}
|
||||
|
||||
// anonymizePeerName replaces the labels in front of a protected suffix with
|
||||
// numbered peer placeholders, keeping the suffix, and records the full
|
||||
// mapping for string replacement in logs. The numbering keeps a peer
|
||||
// recognizable across the whole bundle without disclosing its name.
|
||||
func (a *Anonymizer) anonymizePeerName(baseDomain, suffix string) string {
|
||||
prefix := strings.TrimSuffix(baseDomain, "."+suffix)
|
||||
result := a.anonymizeLabels(prefix, "peer") + "." + suffix
|
||||
if result != baseDomain {
|
||||
a.domainAnonymizer[baseDomain] = result
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// anonymizeLabels replaces each dot-separated label with a consistent
|
||||
// numbered placeholder ("<placeholder>-<n>"). Wildcard labels and
|
||||
// placeholders already handed out pass through unchanged.
|
||||
func (a *Anonymizer) anonymizeLabels(prefix, placeholder string) string {
|
||||
labels := strings.Split(prefix, ".")
|
||||
for i, label := range labels {
|
||||
if label == "*" {
|
||||
continue
|
||||
}
|
||||
if _, ok := a.labelAnonymized[label]; ok {
|
||||
continue
|
||||
}
|
||||
anon, ok := a.labelAnonymizer[label]
|
||||
if !ok {
|
||||
a.labelCounter++
|
||||
anon = fmt.Sprintf("%s-%d", placeholder, a.labelCounter)
|
||||
a.labelAnonymizer[label] = anon
|
||||
a.labelAnonymized[anon] = struct{}{}
|
||||
}
|
||||
labels[i] = anon
|
||||
}
|
||||
return strings.Join(labels, ".")
|
||||
}
|
||||
|
||||
func (a *Anonymizer) AnonymizeURI(uri string) string {
|
||||
u, err := url.Parse(uri)
|
||||
if err != nil {
|
||||
@@ -178,17 +411,75 @@ func (a *Anonymizer) AnonymizeString(str string) string {
|
||||
ipv4Regex := regexp.MustCompile(`\b(?:[0-9]{1,3}\.){3}[0-9]{1,3}\b`)
|
||||
ipv6Regex := regexp.MustCompile(`\b([0-9a-fA-F:]+:+[0-9a-fA-F]{0,4})(?:%[0-9a-zA-Z]+)?(?:\/[0-9]{1,3})?(?::[0-9]{1,5})?\b`)
|
||||
|
||||
// Reverse zones go first and are then held out of the passes below: their
|
||||
// labels are digits, which the address patterns would otherwise consume.
|
||||
str, restoreZones := a.replaceReverseZones(str)
|
||||
|
||||
str = ipv4Regex.ReplaceAllStringFunc(str, a.AnonymizeIPString)
|
||||
str = ipv6Regex.ReplaceAllStringFunc(str, a.AnonymizeIPString)
|
||||
|
||||
for domain, anonDomain := range a.domainAnonymizer {
|
||||
str = strings.ReplaceAll(str, domain, anonDomain)
|
||||
for _, domain := range a.sortedDomains() {
|
||||
str = strings.ReplaceAll(str, domain, a.domainAnonymizer[domain])
|
||||
}
|
||||
|
||||
str = a.AnonymizeSchemeURI(str)
|
||||
str = a.AnonymizeDNSLogLine(str)
|
||||
|
||||
return str
|
||||
// MAC handling runs after the IP passes so preserved IPv6 addresses are
|
||||
// already out of the way; the separator guard skips matches embedded in a
|
||||
// longer colon- or dash-separated sequence (such as an IPv6 tail).
|
||||
str = a.anonymizeMACsInString(str, macColonRegex, ':')
|
||||
str = a.anonymizeMACsInString(str, macDashRegex, '-')
|
||||
|
||||
if a.level >= LevelStrict {
|
||||
str = wgKeyRegex.ReplaceAllStringFunc(str, a.AnonymizeWGKey)
|
||||
}
|
||||
|
||||
return restoreZones(str)
|
||||
}
|
||||
|
||||
// sortedDomains returns the domain mappings longest-first, so a full-FQDN
|
||||
// mapping (strict level) is applied before the base-domain mapping it
|
||||
// contains. The order is rebuilt only when domainAnonymizer has grown.
|
||||
func (a *Anonymizer) sortedDomains() []string {
|
||||
if len(a.domainOrder) == len(a.domainAnonymizer) {
|
||||
return a.domainOrder
|
||||
}
|
||||
|
||||
a.domainOrder = a.domainOrder[:0]
|
||||
for domain := range a.domainAnonymizer {
|
||||
a.domainOrder = append(a.domainOrder, domain)
|
||||
}
|
||||
slices.SortFunc(a.domainOrder, func(x, y string) int {
|
||||
if d := len(y) - len(x); d != 0 {
|
||||
return d
|
||||
}
|
||||
return strings.Compare(x, y)
|
||||
})
|
||||
return a.domainOrder
|
||||
}
|
||||
|
||||
// anonymizeMACsInString replaces MAC addresses matched by re, skipping
|
||||
// matches that directly adjoin another sep so a six-group run inside a longer
|
||||
// separated sequence is left alone.
|
||||
func (a *Anonymizer) anonymizeMACsInString(str string, re *regexp.Regexp, sep byte) string {
|
||||
matches := re.FindAllStringIndex(str, -1)
|
||||
if len(matches) == 0 {
|
||||
return str
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
last := 0
|
||||
for _, m := range matches {
|
||||
if (m[0] > 0 && str[m[0]-1] == sep) || (m[1] < len(str) && str[m[1]] == sep) {
|
||||
continue
|
||||
}
|
||||
b.WriteString(str[last:m[0]])
|
||||
b.WriteString(a.AnonymizeMAC(str[m[0]:m[1]]))
|
||||
last = m[1]
|
||||
}
|
||||
b.WriteString(str[last:])
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// AnonymizeSchemeURI finds and anonymizes URIs with ws, wss, rel, rels, stun, stuns, turn, and turns schemes.
|
||||
@@ -239,10 +530,79 @@ func isWellKnown(addr netip.Addr) bool {
|
||||
"128.0.0.0", "8000::", // 2nd split subnet for default routes
|
||||
}
|
||||
|
||||
if slices.Contains(wellKnown, addr.String()) {
|
||||
return slices.Contains(wellKnown, addr.String())
|
||||
}
|
||||
|
||||
// isInternal reports whether ip identifies a host only within the local
|
||||
// network: IPv4 private (RFC 1918), CGNAT (RFC 6598), and link-local (v4 and
|
||||
// v6). These are preserved at the default level so support can reason about
|
||||
// the real topology, and replaced from the internal pools at the strict
|
||||
// level. IPv6 ULA is deliberately not internal: its random global ID uniquely
|
||||
// fingerprints the network, so it is anonymized at every level.
|
||||
func isInternal(ip netip.Addr) bool {
|
||||
return (ip.Is4() && ip.IsPrivate()) ||
|
||||
ip.IsLinkLocalUnicast() ||
|
||||
isCGNAT(ip)
|
||||
}
|
||||
|
||||
func inPoolRange(ip, start, current netip.Addr) bool {
|
||||
return ip.Compare(start) >= 0 && ip.Compare(current) <= 0
|
||||
}
|
||||
|
||||
// isWellKnownMAC reports whether hw carries no stable host identity: all-zero
|
||||
// or a group address (broadcast and multicast).
|
||||
func isWellKnownMAC(hw net.HardwareAddr) bool {
|
||||
if hw[0]&1 == 1 {
|
||||
return true
|
||||
}
|
||||
for _, b := range hw {
|
||||
if b != 0 {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// looksLikeWGKey reports whether s has the shape of a WireGuard key:
|
||||
// 44 base64 characters decoding to 32 bytes.
|
||||
func looksLikeWGKey(s string) bool {
|
||||
if len(s) != 44 || s[43] != '=' {
|
||||
return false
|
||||
}
|
||||
decoded, err := base64.StdEncoding.DecodeString(s)
|
||||
return err == nil && len(decoded) == 32
|
||||
}
|
||||
|
||||
func generateAnonymousKey() string {
|
||||
buf := make([]byte, 32)
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
return strings.Repeat("A", 43) + "="
|
||||
}
|
||||
return base64.StdEncoding.EncodeToString(buf)
|
||||
}
|
||||
|
||||
// protectedSuffix returns the protected NetBird suffix baseDomain ends with,
|
||||
// or empty. The match is label-anchored so an unrelated domain that merely
|
||||
// ends in the same characters is not preserved.
|
||||
func protectedSuffix(baseDomain string) string {
|
||||
for _, d := range protectedDomains {
|
||||
if baseDomain == d || strings.HasSuffix(baseDomain, "."+d) {
|
||||
return d
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func withTrailingDot(domain string, hasDot bool) string {
|
||||
if hasDot {
|
||||
return domain + "."
|
||||
}
|
||||
return domain
|
||||
}
|
||||
|
||||
// isCGNAT reports whether addr is in 100.64.0.0/10 (RFC 6598), the range
|
||||
// NetBird assigns overlay peer addresses from.
|
||||
func isCGNAT(addr netip.Addr) bool {
|
||||
cgnatRangeStart := netip.AddrFrom4([4]byte{100, 64, 0, 0})
|
||||
cgnatRange := netip.PrefixFrom(cgnatRangeStart, 10)
|
||||
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
package anonymize_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"net/netip"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
@@ -44,6 +47,301 @@ func TestAnonymizeIP(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseLevel(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
expect anonymize.Level
|
||||
}{
|
||||
{"", anonymize.LevelDefault},
|
||||
{"default", anonymize.LevelDefault},
|
||||
{"DEFAULT", anonymize.LevelDefault},
|
||||
{"strict", anonymize.LevelStrict},
|
||||
{"STRICT", anonymize.LevelStrict},
|
||||
// Unknown values must never yield less anonymization than requested.
|
||||
{"garbage", anonymize.LevelStrict},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run("input="+tc.input, func(t *testing.T) {
|
||||
assert.Equal(t, tc.expect, anonymize.ParseLevel(tc.input), "parsed level should match")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnonymizeIP_DefaultLevelInternalRanges(t *testing.T) {
|
||||
anonymizer := anonymize.NewAnonymizer(anonymize.DefaultAddresses())
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
ip string
|
||||
expect string
|
||||
}{
|
||||
{"RFC1918 10/8", "10.1.2.3", "10.1.2.3"},
|
||||
{"RFC1918 172.16/12", "172.16.5.5", "172.16.5.5"},
|
||||
{"RFC1918 192.168/16", "192.168.1.1", "192.168.1.1"},
|
||||
{"CGNAT", "100.64.0.5", "100.64.0.5"},
|
||||
{"IPv4 link-local", "169.254.1.1", "169.254.1.1"},
|
||||
{"IPv6 link-local", "fe80::1", "fe80::1"},
|
||||
// ULA is anonymized even at the default level: its random global ID
|
||||
// uniquely fingerprints the network, unlike shared RFC 1918 space.
|
||||
{"IPv6 ULA", "fd12:3456:789a::1", "2001:db8:ffff::"},
|
||||
// 4-in-6 addresses classify like their unmapped IPv4 form.
|
||||
{"4-in-6 RFC1918", "::ffff:192.168.1.1", "192.168.1.1"},
|
||||
{"4-in-6 CGNAT", "::ffff:100.64.0.5", "100.64.0.5"},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
result := anonymizer.AnonymizeIP(netip.MustParseAddr(tc.ip))
|
||||
assert.Equal(t, tc.expect, result.String(), "default level should preserve internal ranges except ULA")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnonymizeIP_StrictLevel(t *testing.T) {
|
||||
anonymizer := anonymize.NewAnonymizer(anonymize.DefaultAddresses())
|
||||
anonymizer.SetLevel(anonymize.LevelStrict)
|
||||
|
||||
// Order matters: internal pool addresses are assigned sequentially.
|
||||
tests := []struct {
|
||||
name string
|
||||
ip string
|
||||
expect string
|
||||
}{
|
||||
{"RFC1918 192.168/16", "192.168.1.1", "198.18.0.0"},
|
||||
{"Second RFC1918", "192.168.1.2", "198.18.0.1"},
|
||||
{"Repeated RFC1918", "192.168.1.1", "198.18.0.0"},
|
||||
{"RFC1918 10/8", "10.1.2.3", "198.18.0.2"},
|
||||
{"RFC1918 172.16/12", "172.16.5.5", "198.18.0.3"},
|
||||
{"CGNAT", "100.64.0.5", "198.18.0.4"},
|
||||
{"IPv4 link-local", "169.254.1.1", "198.18.0.5"},
|
||||
{"Public IPv4 uses public pool", "1.2.3.4", "198.51.100.0"},
|
||||
{"IPv6 link-local", "fe80::1", "2001:db8:1::"},
|
||||
{"IPv6 ULA", "fd12:3456:789a::1", "2001:db8:1::1"},
|
||||
{"Public IPv6 uses public pool", "2607:f8b0:4005:805::200e", "2001:db8:ffff::"},
|
||||
{"Loopback IPv4", "127.0.0.1", "127.0.0.1"},
|
||||
{"Loopback IPv6", "::1", "::1"},
|
||||
{"Unspecified", "0.0.0.0", "0.0.0.0"},
|
||||
{"Multicast", "224.0.0.251", "224.0.0.251"},
|
||||
{"Well known resolver", "8.8.8.8", "8.8.8.8"},
|
||||
{"Well known split marker", "128.0.0.0", "128.0.0.0"},
|
||||
{"In internal pool range", "198.18.0.3", "198.18.0.3"},
|
||||
{"In public pool range", "198.51.100.0", "198.51.100.0"},
|
||||
{"4-in-6 repeated RFC1918", "::ffff:192.168.1.1", "198.18.0.0"},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
result := anonymizer.AnonymizeIP(netip.MustParseAddr(tc.ip))
|
||||
assert.Equal(t, tc.expect, result.String(), "strict level should replace internal ranges from the internal pools")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnonymizeString_StrictInternalIPs(t *testing.T) {
|
||||
anonymizer := anonymize.NewAnonymizer(anonymize.DefaultAddresses())
|
||||
anonymizer.SetLevel(anonymize.LevelStrict)
|
||||
|
||||
input := "route 10.20.30.0/24 via 192.168.1.1 dev eth0 src 100.64.0.7"
|
||||
firstPass := anonymizer.AnonymizeString(input)
|
||||
secondPass := anonymizer.AnonymizeString(firstPass)
|
||||
|
||||
assert.NotContains(t, firstPass, "10.20.30.0", "private network address should be anonymized")
|
||||
assert.NotContains(t, firstPass, "192.168.1.1", "private gateway should be anonymized")
|
||||
assert.NotContains(t, firstPass, "100.64.0.7", "CGNAT address should be anonymized")
|
||||
assert.Contains(t, firstPass, "/24", "prefix length should be preserved")
|
||||
assert.Equal(t, firstPass, secondPass, "second pass should not further anonymize the string")
|
||||
}
|
||||
|
||||
func TestAnonymizeMAC(t *testing.T) {
|
||||
anonymizer := anonymize.NewAnonymizer(anonymize.DefaultAddresses())
|
||||
|
||||
first := anonymizer.AnonymizeMAC("aa:bb:cc:dd:ee:0f")
|
||||
assert.Equal(t, "02:00:00:00:00:01", first, "first MAC should get the first placeholder")
|
||||
assert.Equal(t, first, anonymizer.AnonymizeMAC("aa:bb:cc:dd:ee:0f"), "repeated MAC should map to the same placeholder")
|
||||
assert.Equal(t, first, anonymizer.AnonymizeMAC("AA:BB:CC:DD:EE:0F"), "case should not affect the mapping")
|
||||
assert.Equal(t, "02-00-00-00-00-01", anonymizer.AnonymizeMAC("AA-BB-CC-DD-EE-0F"), "dash form should keep its separator but share the mapping")
|
||||
|
||||
second := anonymizer.AnonymizeMAC("10:22:33:44:55:66")
|
||||
assert.Equal(t, "02:00:00:00:00:02", second, "second distinct MAC should get the next placeholder")
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
mac string
|
||||
}{
|
||||
{"Broadcast", "ff:ff:ff:ff:ff:ff"},
|
||||
{"IPv4 multicast", "01:00:5e:00:00:fb"},
|
||||
{"IPv6 multicast", "33:33:00:00:00:01"},
|
||||
{"All zero", "00:00:00:00:00:00"},
|
||||
{"Assigned placeholder", "02:00:00:00:00:01"},
|
||||
{"Invalid", "not-a-mac"},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
assert.Equal(t, tc.mac, anonymizer.AnonymizeMAC(tc.mac), "should be preserved")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnonymizeString_MACAddresses(t *testing.T) {
|
||||
anonymizer := anonymize.NewAnonymizer(anonymize.DefaultAddresses())
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
expect string
|
||||
}{
|
||||
{
|
||||
name: "nftables ether rule",
|
||||
input: "ether saddr aa:bb:cc:dd:ee:ff drop",
|
||||
expect: "ether saddr 02:00:00:00:00:01 drop",
|
||||
},
|
||||
{
|
||||
name: "Windows dash form",
|
||||
input: "Physical Address : AA-BB-CC-DD-EE-FF",
|
||||
expect: "Physical Address : 02-00-00-00-00-01",
|
||||
},
|
||||
{
|
||||
name: "IPv6 address tail is not treated as MAC",
|
||||
input: "addr fe80:0:11:22:33:44:55:66 scope link",
|
||||
expect: "addr fe80:0:11:22:33:44:55:66 scope link",
|
||||
},
|
||||
{
|
||||
name: "broadcast MAC preserved",
|
||||
input: "dst ff:ff:ff:ff:ff:ff type ARP",
|
||||
expect: "dst ff:ff:ff:ff:ff:ff type ARP",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
result := anonymizer.AnonymizeString(tc.input)
|
||||
assert.Equal(t, tc.expect, result, "MAC addresses should be anonymized at every level")
|
||||
assert.Equal(t, result, anonymizer.AnonymizeString(result), "second pass should not change the result")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnonymizeWGKey(t *testing.T) {
|
||||
key := base64.StdEncoding.EncodeToString(bytes.Repeat([]byte{0x42}, 32))
|
||||
|
||||
t.Run("default level preserves keys", func(t *testing.T) {
|
||||
anonymizer := anonymize.NewAnonymizer(anonymize.DefaultAddresses())
|
||||
assert.Equal(t, key, anonymizer.AnonymizeWGKey(key), "default level should not touch WireGuard keys")
|
||||
})
|
||||
|
||||
t.Run("strict level replaces keys", func(t *testing.T) {
|
||||
anonymizer := anonymize.NewAnonymizer(anonymize.DefaultAddresses())
|
||||
anonymizer.SetLevel(anonymize.LevelStrict)
|
||||
|
||||
anon := anonymizer.AnonymizeWGKey(key)
|
||||
assert.NotEqual(t, key, anon, "strict level should replace the key")
|
||||
assert.Regexp(t, `^[A-Za-z0-9+/]{43}=$`, anon, "placeholder should keep the WireGuard key shape")
|
||||
assert.Equal(t, anon, anonymizer.AnonymizeWGKey(key), "repeated key should map to the same placeholder")
|
||||
assert.Equal(t, anon, anonymizer.AnonymizeWGKey(anon), "an assigned placeholder should pass through unchanged")
|
||||
|
||||
assert.Equal(t, "not-a-key", anonymizer.AnonymizeWGKey("not-a-key"), "non-key values should be preserved")
|
||||
})
|
||||
}
|
||||
|
||||
func TestAnonymizeString_WGKeys(t *testing.T) {
|
||||
key := base64.StdEncoding.EncodeToString(bytes.Repeat([]byte{0x42}, 32))
|
||||
input := "peer " + key + " handshake completed"
|
||||
|
||||
t.Run("default level preserves keys", func(t *testing.T) {
|
||||
anonymizer := anonymize.NewAnonymizer(anonymize.DefaultAddresses())
|
||||
assert.Equal(t, input, anonymizer.AnonymizeString(input), "default level should not touch WireGuard keys in strings")
|
||||
})
|
||||
|
||||
t.Run("strict level replaces keys", func(t *testing.T) {
|
||||
anonymizer := anonymize.NewAnonymizer(anonymize.DefaultAddresses())
|
||||
anonymizer.SetLevel(anonymize.LevelStrict)
|
||||
|
||||
firstPass := anonymizer.AnonymizeString(input)
|
||||
assert.NotContains(t, firstPass, key, "the key should not survive strict anonymization")
|
||||
assert.Equal(t, anonymizer.AnonymizeWGKey(key), extractKey(t, firstPass), "string replacement should be consistent with AnonymizeWGKey")
|
||||
assert.Equal(t, firstPass, anonymizer.AnonymizeString(firstPass), "second pass should not change the result")
|
||||
})
|
||||
}
|
||||
|
||||
func extractKey(t *testing.T, logLine string) string {
|
||||
t.Helper()
|
||||
fields := strings.Fields(logLine)
|
||||
require.Len(t, fields, 4, "log line should keep its structure")
|
||||
return fields[1]
|
||||
}
|
||||
|
||||
func TestAnonymizeDomain_StrictLevel(t *testing.T) {
|
||||
anonymizer := anonymize.NewAnonymizer(anonymize.DefaultAddresses())
|
||||
anonymizer.SetLevel(anonymize.LevelStrict)
|
||||
|
||||
t.Run("netbird peer name", func(t *testing.T) {
|
||||
result := anonymizer.AnonymizeDomain("my-laptop.netbird.cloud")
|
||||
assert.Regexp(t, `^peer-\d+\.netbird\.cloud$`, result, "peer name should be anonymized, suffix kept")
|
||||
assert.NotContains(t, result, "my-laptop", "the peer name should not survive")
|
||||
assert.Equal(t, result, anonymizer.AnonymizeDomain("my-laptop.netbird.cloud"), "repeated domain should map consistently")
|
||||
assert.Equal(t, result, anonymizer.AnonymizeDomain(result), "an anonymized domain should pass through unchanged")
|
||||
})
|
||||
|
||||
t.Run("bare netbird domain", func(t *testing.T) {
|
||||
assert.Equal(t, "netbird.cloud", anonymizer.AnonymizeDomain("netbird.cloud"), "the bare protected suffix should be preserved")
|
||||
})
|
||||
|
||||
t.Run("netbird infrastructure preserved", func(t *testing.T) {
|
||||
assert.Equal(t, "api.netbird.io", anonymizer.AnonymizeDomain("api.netbird.io"),
|
||||
"netbird.io hosts infrastructure, not peer names, and should stay readable")
|
||||
})
|
||||
|
||||
t.Run("leading labels of other domains", func(t *testing.T) {
|
||||
result := anonymizer.AnonymizeDomain("host1.corp.example.com")
|
||||
assert.Regexp(t, `^host-\d+\.host-\d+\.anon-[a-zA-Z0-9]+\.domain$`, result, "every label should be anonymized")
|
||||
for _, label := range []string{"host1", "corp", "example"} {
|
||||
assert.NotContains(t, result, label, "no original label should survive")
|
||||
}
|
||||
assert.Equal(t, result, anonymizer.AnonymizeDomain("host1.corp.example.com"), "repeated domain should map consistently")
|
||||
})
|
||||
|
||||
t.Run("same label maps consistently across domains", func(t *testing.T) {
|
||||
first := anonymizer.AnonymizeDomain("shared.one.com")
|
||||
second := anonymizer.AnonymizeDomain("shared.two.com")
|
||||
assert.Equal(t, strings.Split(first, ".")[0], strings.Split(second, ".")[0], "the shared host label should get one placeholder")
|
||||
})
|
||||
|
||||
t.Run("wildcard label preserved", func(t *testing.T) {
|
||||
result := anonymizer.AnonymizeDomain("*.example.com")
|
||||
assert.Regexp(t, `^\*\.anon-[a-zA-Z0-9]+\.domain$`, result, "the wildcard label should stay a wildcard")
|
||||
})
|
||||
}
|
||||
|
||||
func TestAnonymizeDomain_DefaultLevelKeepsPeerNames(t *testing.T) {
|
||||
anonymizer := anonymize.NewAnonymizer(anonymize.DefaultAddresses())
|
||||
|
||||
assert.Equal(t, "my-laptop.netbird.cloud", anonymizer.AnonymizeDomain("my-laptop.netbird.cloud"),
|
||||
"default level should preserve netbird FQDNs including the peer name")
|
||||
assert.Regexp(t, `^sub\.anon-[a-zA-Z0-9]+\.domain$`, anonymizer.AnonymizeDomain("sub.example.com"),
|
||||
"default level should keep subdomain labels")
|
||||
}
|
||||
|
||||
func TestAnonymizeString_StrictPeerNames(t *testing.T) {
|
||||
anonymizer := anonymize.NewAnonymizer(anonymize.DefaultAddresses())
|
||||
anonymizer.SetLevel(anonymize.LevelStrict)
|
||||
|
||||
// Seed like the bundle generator does from the status: base first, then
|
||||
// the full FQDN, so replacement must prefer the longer mapping.
|
||||
anonBase := anonymizer.AnonymizeDomain("example.com")
|
||||
anonPeer := anonymizer.AnonymizeDomain("peer1.netbird.cloud")
|
||||
anonHost := anonymizer.AnonymizeDomain("host1.example.com")
|
||||
|
||||
logLine := "connected to peer1.netbird.cloud via host1.example.com endpoint"
|
||||
firstPass := anonymizer.AnonymizeString(logLine)
|
||||
assert.NotContains(t, firstPass, "peer1", "the peer name should not survive in logs")
|
||||
assert.NotContains(t, firstPass, "host1", "the host label should not survive in logs")
|
||||
assert.Contains(t, firstPass, anonPeer, "the seeded peer mapping should be applied")
|
||||
assert.Contains(t, firstPass, anonHost, "the seeded host mapping should be applied, not just the base mapping")
|
||||
assert.NotContains(t, firstPass, "host1."+anonBase, "the base mapping must not preempt the longer FQDN mapping")
|
||||
assert.Equal(t, firstPass, anonymizer.AnonymizeString(firstPass), "second pass should not change the result")
|
||||
}
|
||||
|
||||
func TestAnonymizeDNSLogLine(t *testing.T) {
|
||||
anonymizer := anonymize.NewAnonymizer(netip.Addr{}, netip.Addr{})
|
||||
tests := []struct {
|
||||
|
||||
174
client/anonymize/reverse_zone.go
Normal file
174
client/anonymize/reverse_zone.go
Normal file
@@ -0,0 +1,174 @@
|
||||
package anonymize
|
||||
|
||||
import (
|
||||
"encoding/hex"
|
||||
"net/netip"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
reverseZoneSuffixV4 = ".in-addr.arpa"
|
||||
reverseZoneSuffixV6 = ".ip6.arpa"
|
||||
|
||||
v6Nibbles = 32
|
||||
v4Octets = 4
|
||||
)
|
||||
|
||||
// reverseZoneRegexes match a reverse zone or a full reverse name in free text.
|
||||
// They are applied before the address passes of AnonymizeString, whose IPv4
|
||||
// pattern would otherwise consume the digit labels of a zone and replace parts
|
||||
// of it with unrelated addresses.
|
||||
var reverseZoneRegexes = []*regexp.Regexp{
|
||||
regexp.MustCompile(`(?:[0-9]{1,3}\.){1,4}in-addr\.arpa\b`),
|
||||
regexp.MustCompile(`(?:[0-9a-fA-F]\.){1,32}ip6\.arpa\b`),
|
||||
}
|
||||
|
||||
// anonymizeReverseZone maps a reverse zone to the zone of the anonymized form
|
||||
// of the prefix it encodes, so it follows the address rules rather than the
|
||||
// domain ones: the zone of an address that is preserved is preserved too, and
|
||||
// the zone of one that is replaced names the replacement. This keeps a reverse
|
||||
// zone recognizable as such, and consistent with the addresses it belongs to
|
||||
// elsewhere in the same output. It reports false for anything that is not a
|
||||
// reverse zone.
|
||||
func (a *Anonymizer) anonymizeReverseZone(domain string) (string, bool) {
|
||||
prefix, labelCount, suffix, ok := parseReverseZone(domain)
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
|
||||
anonymized := a.AnonymizeIP(prefix)
|
||||
if anonymized == prefix {
|
||||
return domain, true
|
||||
}
|
||||
|
||||
return reverseZoneName(anonymized, labelCount) + suffix, true
|
||||
}
|
||||
|
||||
// replaceReverseZones anonymizes every reverse zone in str and swaps each one
|
||||
// for a placeholder, returning a function that puts the anonymized zones back.
|
||||
// The placeholders carry no dots, digits or colons, so no later pass matches
|
||||
// them.
|
||||
func (a *Anonymizer) replaceReverseZones(str string) (string, func(string) string) {
|
||||
var zones []string
|
||||
|
||||
for _, re := range reverseZoneRegexes {
|
||||
str = re.ReplaceAllStringFunc(str, func(match string) string {
|
||||
zone, ok := a.anonymizeReverseZone(match)
|
||||
if !ok {
|
||||
return match
|
||||
}
|
||||
|
||||
zones = append(zones, zone)
|
||||
return reverseZonePlaceholder(len(zones) - 1)
|
||||
})
|
||||
}
|
||||
|
||||
if len(zones) == 0 {
|
||||
return str, func(s string) string { return s }
|
||||
}
|
||||
|
||||
return str, func(s string) string {
|
||||
for i, zone := range zones {
|
||||
s = strings.ReplaceAll(s, reverseZonePlaceholder(i), zone)
|
||||
}
|
||||
return s
|
||||
}
|
||||
}
|
||||
|
||||
func reverseZonePlaceholder(index int) string {
|
||||
return "\x00reversezone" + strconv.Itoa(index) + "\x00"
|
||||
}
|
||||
|
||||
// parseReverseZone turns a reverse zone into the address of the prefix its
|
||||
// labels spell backwards, padding the absent low-order part with zeroes, and
|
||||
// returns the label count and zone suffix so the name can be rebuilt.
|
||||
func parseReverseZone(domain string) (netip.Addr, int, string, bool) {
|
||||
lower := strings.ToLower(domain)
|
||||
|
||||
switch {
|
||||
case strings.HasSuffix(lower, reverseZoneSuffixV4):
|
||||
labels := strings.Split(strings.TrimSuffix(lower, reverseZoneSuffixV4), ".")
|
||||
addr, ok := reverseZoneAddrV4(labels)
|
||||
return addr, len(labels), reverseZoneSuffixV4, ok
|
||||
case strings.HasSuffix(lower, reverseZoneSuffixV6):
|
||||
labels := strings.Split(strings.TrimSuffix(lower, reverseZoneSuffixV6), ".")
|
||||
addr, ok := reverseZoneAddrV6(labels)
|
||||
return addr, len(labels), reverseZoneSuffixV6, ok
|
||||
default:
|
||||
return netip.Addr{}, 0, "", false
|
||||
}
|
||||
}
|
||||
|
||||
func reverseZoneAddrV4(labels []string) (netip.Addr, bool) {
|
||||
if len(labels) == 0 || len(labels) > v4Octets {
|
||||
return netip.Addr{}, false
|
||||
}
|
||||
|
||||
var octets [v4Octets]byte
|
||||
for i, label := range labels {
|
||||
octet, err := strconv.ParseUint(label, 10, 8)
|
||||
if err != nil {
|
||||
return netip.Addr{}, false
|
||||
}
|
||||
octets[len(labels)-1-i] = byte(octet)
|
||||
}
|
||||
|
||||
return netip.AddrFrom4(octets), true
|
||||
}
|
||||
|
||||
func reverseZoneAddrV6(labels []string) (netip.Addr, bool) {
|
||||
if len(labels) == 0 || len(labels) > v6Nibbles {
|
||||
return netip.Addr{}, false
|
||||
}
|
||||
|
||||
nibbles := make([]byte, 0, v6Nibbles)
|
||||
for i := len(labels) - 1; i >= 0; i-- {
|
||||
if len(labels[i]) != 1 || !isHexDigit(labels[i][0]) {
|
||||
return netip.Addr{}, false
|
||||
}
|
||||
nibbles = append(nibbles, labels[i][0])
|
||||
}
|
||||
for len(nibbles) < v6Nibbles {
|
||||
nibbles = append(nibbles, '0')
|
||||
}
|
||||
|
||||
var groups []string
|
||||
for i := 0; i < len(nibbles); i += 4 {
|
||||
groups = append(groups, string(nibbles[i:i+4]))
|
||||
}
|
||||
|
||||
addr, err := netip.ParseAddr(strings.Join(groups, ":"))
|
||||
if err != nil {
|
||||
return netip.Addr{}, false
|
||||
}
|
||||
|
||||
return addr, true
|
||||
}
|
||||
|
||||
// reverseZoneName spells the first labelCount labels of addr backwards, the
|
||||
// inverse of parseReverseZone, without the zone suffix.
|
||||
func reverseZoneName(addr netip.Addr, labelCount int) string {
|
||||
labels := make([]string, 0, labelCount)
|
||||
|
||||
if addr.Is4() {
|
||||
octets := addr.As4()
|
||||
for i := labelCount - 1; i >= 0; i-- {
|
||||
labels = append(labels, strconv.Itoa(int(octets[i])))
|
||||
}
|
||||
return strings.Join(labels, ".")
|
||||
}
|
||||
|
||||
address := addr.As16()
|
||||
nibbles := hex.EncodeToString(address[:])
|
||||
for i := labelCount - 1; i >= 0; i-- {
|
||||
labels = append(labels, string(nibbles[i]))
|
||||
}
|
||||
|
||||
return strings.Join(labels, ".")
|
||||
}
|
||||
|
||||
func isHexDigit(c byte) bool {
|
||||
return c >= '0' && c <= '9' || c >= 'a' && c <= 'f' || c >= 'A' && c <= 'F'
|
||||
}
|
||||
171
client/anonymize/reverse_zone_test.go
Normal file
171
client/anonymize/reverse_zone_test.go
Normal file
@@ -0,0 +1,171 @@
|
||||
package anonymize
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func newLeveledAnonymizer(level Level) *Anonymizer {
|
||||
a := NewAnonymizer(DefaultAddresses())
|
||||
a.SetLevel(level)
|
||||
return a
|
||||
}
|
||||
|
||||
// TestAnonymizeDomainReverseZone covers reverse zones going through the address
|
||||
// rules instead of the domain ones, so a zone stays a zone and an address that
|
||||
// is preserved keeps the zone that names it.
|
||||
func TestAnonymizeDomainReverseZone(t *testing.T) {
|
||||
// 100.64.0.0/10 is the overlay range, which is CGNAT: preserved at the
|
||||
// default level and replaced from the internal pool at the strict one
|
||||
const overlayZone = "64.100.in-addr.arpa"
|
||||
|
||||
t.Run("overlay zone preserved at the default level", func(t *testing.T) {
|
||||
a := newLeveledAnonymizer(LevelDefault)
|
||||
assert.Equal(t, overlayZone, a.AnonymizeDomain(overlayZone), "should keep the zone of a preserved address")
|
||||
})
|
||||
|
||||
t.Run("private zone preserved at the default level", func(t *testing.T) {
|
||||
a := newLeveledAnonymizer(LevelDefault)
|
||||
assert.Equal(t, "168.192.in-addr.arpa", a.AnonymizeDomain("168.192.in-addr.arpa"), "should keep the zone of a private address")
|
||||
})
|
||||
|
||||
t.Run("overlay zone replaced at the strict level", func(t *testing.T) {
|
||||
a := newLeveledAnonymizer(LevelStrict)
|
||||
|
||||
got := a.AnonymizeDomain(overlayZone)
|
||||
require.True(t, strings.HasSuffix(got, reverseZoneSuffixV4), "should stay a reverse zone, got %q", got)
|
||||
assert.NotEqual(t, overlayZone, got, "should replace the encoded prefix")
|
||||
assert.Len(t, strings.Split(strings.TrimSuffix(got, reverseZoneSuffixV4), "."), 2,
|
||||
"should keep the label count, got %q", got)
|
||||
})
|
||||
|
||||
t.Run("public zone replaced at the default level", func(t *testing.T) {
|
||||
a := newLeveledAnonymizer(LevelDefault)
|
||||
|
||||
got := a.AnonymizeDomain("113.0.203.in-addr.arpa")
|
||||
require.True(t, strings.HasSuffix(got, reverseZoneSuffixV4), "should stay a reverse zone, got %q", got)
|
||||
assert.NotEqual(t, "113.0.203.in-addr.arpa", got, "should replace a public prefix")
|
||||
})
|
||||
|
||||
t.Run("zone of an address keeps that address mapping", func(t *testing.T) {
|
||||
a := newLeveledAnonymizer(LevelDefault)
|
||||
|
||||
anonymizedAddr := a.AnonymizeIPString("203.0.113.7")
|
||||
got := a.AnonymizeDomain("7.113.0.203.in-addr.arpa")
|
||||
|
||||
octets := strings.Split(anonymizedAddr, ".")
|
||||
want := octets[3] + "." + octets[2] + "." + octets[1] + "." + octets[0] + reverseZoneSuffixV4
|
||||
assert.Equal(t, want, got, "should name the same replacement as the address itself")
|
||||
})
|
||||
|
||||
t.Run("ipv6 nibble labels stay single digits", func(t *testing.T) {
|
||||
a := newLeveledAnonymizer(LevelDefault)
|
||||
|
||||
zone := "0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.2.0.0.0" + reverseZoneSuffixV6
|
||||
got := a.AnonymizeDomain(zone)
|
||||
|
||||
require.True(t, strings.HasSuffix(got, reverseZoneSuffixV6), "should stay a reverse zone, got %q", got)
|
||||
labels := strings.Split(strings.TrimSuffix(got, reverseZoneSuffixV6), ".")
|
||||
assert.Len(t, labels, 28, "should keep every nibble label, got %q", got)
|
||||
for _, label := range labels {
|
||||
assert.Len(t, label, 1, "nibble label %q should stay a single digit", label)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("trailing dot is kept", func(t *testing.T) {
|
||||
a := newLeveledAnonymizer(LevelDefault)
|
||||
assert.Equal(t, "64.100.in-addr.arpa.", a.AnonymizeDomain("64.100.in-addr.arpa."), "should keep the trailing dot")
|
||||
})
|
||||
|
||||
t.Run("a domain that only looks like a zone is anonymized as a domain", func(t *testing.T) {
|
||||
a := newLeveledAnonymizer(LevelDefault)
|
||||
|
||||
got := a.AnonymizeDomain("not-a-zone.in-addr.arpa")
|
||||
assert.NotContains(t, got, "in-addr.arpa", "should fall back to domain anonymization")
|
||||
})
|
||||
}
|
||||
|
||||
// TestAnonymizeStringReverseZone verifies that a zone inside free text, such as
|
||||
// a DNS log line, is not chewed up by the address passes. The IPv4 pattern
|
||||
// matches any run of dotted digits, which a reverse zone is made of.
|
||||
func TestAnonymizeStringReverseZone(t *testing.T) {
|
||||
t.Run("ipv6 zone survives the address passes", func(t *testing.T) {
|
||||
a := newLeveledAnonymizer(LevelDefault)
|
||||
|
||||
zone := "0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.2.0.0.0" + reverseZoneSuffixV6
|
||||
got := a.AnonymizeString("question: domain=" + zone + " type=PTR")
|
||||
|
||||
assert.Contains(t, got, "type=PTR", "should keep the rest of the line")
|
||||
assert.NotContains(t, got, "198.51.100", "should not rewrite nibble labels as an address")
|
||||
|
||||
labels := strings.Split(strings.TrimSuffix(strings.TrimPrefix(got, "question: domain="), reverseZoneSuffixV6+" type=PTR"), ".")
|
||||
assert.Len(t, labels, 28, "should keep every nibble label, got %q", got)
|
||||
})
|
||||
|
||||
t.Run("preserved ipv4 zone is untouched", func(t *testing.T) {
|
||||
a := newLeveledAnonymizer(LevelDefault)
|
||||
|
||||
line := "reverse zone 64.100.in-addr.arpa registered"
|
||||
assert.Equal(t, line, a.AnonymizeString(line), "should keep the zone of a preserved address")
|
||||
})
|
||||
|
||||
t.Run("public ipv4 zone is replaced consistently", func(t *testing.T) {
|
||||
a := newLeveledAnonymizer(LevelDefault)
|
||||
|
||||
got := a.AnonymizeString("zone 113.0.203.in-addr.arpa and address 203.0.113.7")
|
||||
assert.NotContains(t, got, "113.0.203.in-addr.arpa", "should replace the zone")
|
||||
assert.NotContains(t, got, "203.0.113.7", "should replace the address")
|
||||
assert.Contains(t, got, reverseZoneSuffixV4, "should keep the zone suffix")
|
||||
})
|
||||
}
|
||||
|
||||
func TestParseReverseZone(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
zone string
|
||||
addr string
|
||||
labels int
|
||||
}{
|
||||
{name: "v4 two labels", zone: "0.100" + reverseZoneSuffixV4, addr: "100.0.0.0", labels: 2},
|
||||
{name: "v4 three labels", zone: "1.168.192" + reverseZoneSuffixV4, addr: "192.168.1.0", labels: 3},
|
||||
{name: "v4 full address", zone: "7.113.0.203" + reverseZoneSuffixV4, addr: "203.0.113.7", labels: 4},
|
||||
{
|
||||
name: "v6 prefix",
|
||||
zone: "0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.2.0.0.0" + reverseZoneSuffixV6,
|
||||
addr: "2::",
|
||||
labels: 28,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
addr, labels, suffix, ok := parseReverseZone(tc.zone)
|
||||
require.True(t, ok, "should decode the reverse zone")
|
||||
assert.Equal(t, tc.addr, addr.String(), "should decode to the encoded prefix")
|
||||
assert.Equal(t, tc.labels, labels, "should count the labels")
|
||||
assert.Equal(t, tc.zone, reverseZoneName(addr, labels)+suffix, "should re-encode to the original zone")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseReverseZoneRejectsNonZones(t *testing.T) {
|
||||
tests := []string{
|
||||
"example.com",
|
||||
"in-addr.arpa",
|
||||
"x.100" + reverseZoneSuffixV4,
|
||||
"256" + reverseZoneSuffixV4,
|
||||
"1.2.3.4.5" + reverseZoneSuffixV4,
|
||||
"ab" + reverseZoneSuffixV6,
|
||||
"g" + reverseZoneSuffixV6,
|
||||
}
|
||||
|
||||
for _, zone := range tests {
|
||||
t.Run(zone, func(t *testing.T) {
|
||||
_, _, _, ok := parseReverseZone(zone)
|
||||
assert.False(t, ok, "should reject %q", zone)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -27,8 +27,8 @@ import (
|
||||
const errCloseConnection = "Failed to close connection: %v"
|
||||
|
||||
var (
|
||||
logFileCount uint32
|
||||
systemInfoFlag bool
|
||||
logFileCount uint32
|
||||
systemInfoFlag bool
|
||||
uploadBundleFlag bool
|
||||
uploadBundleURLFlag string
|
||||
uploadBundleInsecureFlag bool
|
||||
@@ -156,6 +156,11 @@ func debugConfigDump(cmd *cobra.Command, _ []string) error {
|
||||
// request. Returns an error if the RPC fails or if the daemon reports
|
||||
// an upload failure reason.
|
||||
func debugBundle(cmd *cobra.Command, _ []string) error {
|
||||
anonymizeEnabled, anonymizeLevel, err := effectiveAnonymize()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
conn, err := getClient(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -168,10 +173,11 @@ func debugBundle(cmd *cobra.Command, _ []string) error {
|
||||
|
||||
client := proto.NewDaemonServiceClient(conn)
|
||||
request := &proto.DebugBundleRequest{
|
||||
Anonymize: anonymizeFlag,
|
||||
SystemInfo: systemInfoFlag,
|
||||
LogFileCount: logFileCount,
|
||||
CliVersion: version.NetbirdVersion(),
|
||||
Anonymize: anonymizeEnabled,
|
||||
AnonymizeLevel: anonymizeLevel.String(),
|
||||
SystemInfo: systemInfoFlag,
|
||||
LogFileCount: logFileCount,
|
||||
CliVersion: version.NetbirdVersion(),
|
||||
}
|
||||
if uploadBundleFlag {
|
||||
request.UploadURL = uploadBundleURLFlag
|
||||
@@ -229,6 +235,11 @@ func runForDuration(cmd *cobra.Command, args []string) error {
|
||||
return fmt.Errorf("invalid duration format: %v", err)
|
||||
}
|
||||
|
||||
anonymizeEnabled, anonymizeLevel, err := effectiveAnonymize()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
conn, err := getClient(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -368,10 +379,11 @@ func runForDuration(cmd *cobra.Command, args []string) error {
|
||||
cmd.Println("Creating debug bundle...")
|
||||
|
||||
request := &proto.DebugBundleRequest{
|
||||
Anonymize: anonymizeFlag,
|
||||
SystemInfo: systemInfoFlag,
|
||||
LogFileCount: logFileCount,
|
||||
CliVersion: version.NetbirdVersion(),
|
||||
Anonymize: anonymizeEnabled,
|
||||
AnonymizeLevel: anonymizeLevel.String(),
|
||||
SystemInfo: systemInfoFlag,
|
||||
LogFileCount: logFileCount,
|
||||
CliVersion: version.NetbirdVersion(),
|
||||
}
|
||||
if uploadBundleFlag {
|
||||
request.UploadURL = uploadBundleURLFlag
|
||||
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/user"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
@@ -121,7 +120,7 @@ func doDaemonLogin(ctx context.Context, cmd *cobra.Command, providedSetupKey str
|
||||
loginRequest := proto.LoginRequest{
|
||||
SetupKey: providedSetupKey,
|
||||
ManagementUrl: managementURL,
|
||||
IsUnixDesktopClient: isUnixRunningDesktop(),
|
||||
IsUnixDesktopClient: util.HasGraphicalSession(),
|
||||
Hostname: hostName,
|
||||
DnsLabels: dnsLabelsReq,
|
||||
ProfileName: &handle,
|
||||
@@ -189,7 +188,8 @@ func doExtendSession(ctx context.Context, cmd *cobra.Command) error {
|
||||
|
||||
client := proto.NewDaemonServiceClient(conn)
|
||||
|
||||
req := &proto.RequestExtendAuthSessionRequest{}
|
||||
// the CLI runs in the user's session, the daemon does not: tell it what we can see
|
||||
req := &proto.RequestExtendAuthSessionRequest{HasGraphicalSession: util.HasGraphicalSession()}
|
||||
// Pre-fill the IdP login hint from the active profile so the user
|
||||
// doesn't have to retype their email. Best-effort: we still proceed
|
||||
// without a hint if the lookup fails.
|
||||
@@ -408,7 +408,7 @@ func foregroundGetTokenInfo(ctx context.Context, cmd *cobra.Command, config *pro
|
||||
hint = profileState.Email
|
||||
}
|
||||
|
||||
oAuthFlow, err := auth.NewOAuthFlow(ctx, config, isUnixRunningDesktop(), false, hint)
|
||||
oAuthFlow, err := auth.NewOAuthFlow(ctx, config, util.HasGraphicalSession(), false, hint)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -458,14 +458,6 @@ func openURL(cmd *cobra.Command, verificationURIComplete, userCode string, noBro
|
||||
}
|
||||
}
|
||||
|
||||
// isUnixRunningDesktop checks if a Linux OS is running desktop environment
|
||||
func isUnixRunningDesktop() bool {
|
||||
if runtime.GOOS != "linux" && runtime.GOOS != "freebsd" {
|
||||
return false
|
||||
}
|
||||
return os.Getenv("DESKTOP_SESSION") != "" || os.Getenv("XDG_CURRENT_DESKTOP") != ""
|
||||
}
|
||||
|
||||
func setEnvAndFlags(cmd *cobra.Command) error {
|
||||
SetFlagsFromEnvVars(rootCmd)
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ import (
|
||||
"github.com/spf13/pflag"
|
||||
"google.golang.org/grpc"
|
||||
|
||||
"github.com/netbirdio/netbird/client/anonymize"
|
||||
daddr "github.com/netbirdio/netbird/client/internal/daemonaddr"
|
||||
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
||||
)
|
||||
@@ -69,6 +70,7 @@ var (
|
||||
autoConnectDisabled bool
|
||||
extraIFaceBlackList []string
|
||||
anonymizeFlag bool
|
||||
anonymizeLevelFlag string
|
||||
dnsRouteInterval time.Duration
|
||||
// lazyConnEnabled is the parse target for the deprecated --enable-lazy-connection
|
||||
// flag. The flag is inert; the value is no longer read (use NB_LAZY_CONN instead).
|
||||
@@ -156,7 +158,8 @@ func init() {
|
||||
rootCmd.MarkFlagsMutuallyExclusive("setup-key", "setup-key-file")
|
||||
rootCmd.PersistentFlags().StringVar(&preSharedKey, preSharedKeyFlag, "", "Sets WireGuard PreSharedKey property. If set, then only peers that have the same key can communicate.")
|
||||
rootCmd.PersistentFlags().StringVarP(&hostName, "hostname", "n", "", "Sets a custom hostname for the device")
|
||||
rootCmd.PersistentFlags().BoolVarP(&anonymizeFlag, "anonymize", "A", false, "anonymize IP addresses and non-netbird.io domains in logs and status output")
|
||||
rootCmd.PersistentFlags().BoolVarP(&anonymizeFlag, "anonymize", "A", false, "anonymize public IP addresses, MAC addresses, and non-netbird.io domains in logs and status output; private, CGNAT, and link-local IP ranges are kept (see --anonymize-level strict)")
|
||||
rootCmd.PersistentFlags().StringVar(&anonymizeLevelFlag, "anonymize-level", "", "anonymization level: \"default\" or \"strict\"; strict also anonymizes private, CGNAT, and link-local IP ranges, peer names, and WireGuard public keys. Setting this flag implies --anonymize")
|
||||
rootCmd.PersistentFlags().StringVarP(&configPath, "config", "c", profilemanager.DefaultConfigPath, "Overrides the default profile file location")
|
||||
|
||||
rootCmd.AddCommand(upCmd)
|
||||
@@ -293,6 +296,19 @@ var CLIBackOffSettings = &backoff.ExponentialBackOff{
|
||||
Clock: backoff.SystemClock,
|
||||
}
|
||||
|
||||
// effectiveAnonymize resolves the --anonymize and --anonymize-level flags:
|
||||
// setting a level implies anonymization, and an invalid level is rejected.
|
||||
func effectiveAnonymize() (bool, anonymize.Level, error) {
|
||||
if anonymizeLevelFlag == "" {
|
||||
return anonymizeFlag, anonymize.LevelDefault, nil
|
||||
}
|
||||
level := anonymize.ParseLevel(anonymizeLevelFlag)
|
||||
if !strings.EqualFold(anonymizeLevelFlag, level.String()) {
|
||||
return false, anonymize.LevelDefault, fmt.Errorf("invalid anonymize level %q: use %q or %q", anonymizeLevelFlag, anonymize.LevelDefault.String(), anonymize.LevelStrict.String())
|
||||
}
|
||||
return true, level, nil
|
||||
}
|
||||
|
||||
func getSetupKey() (string, error) {
|
||||
if setupKeyPath != "" && setupKey == "" {
|
||||
return getSetupKeyFromFile(setupKeyPath)
|
||||
|
||||
@@ -121,8 +121,14 @@ func statusFunc(cmd *cobra.Command, args []string) error {
|
||||
sessionExpiresAt = ts.AsTime().UTC()
|
||||
}
|
||||
|
||||
anonymizeEnabled, anonymizeLevel, err := effectiveAnonymize()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var outputInformationHolder = nbstatus.ConvertToStatusOutputOverview(resp.GetFullStatus(), nbstatus.ConvertOptions{
|
||||
Anonymize: anonymizeFlag,
|
||||
Anonymize: anonymizeEnabled,
|
||||
AnonymizeLevel: anonymizeLevel,
|
||||
DaemonVersion: resp.GetDaemonVersion(),
|
||||
DaemonStatus: nbstatus.ParseDaemonStatus(status),
|
||||
StatusFilter: statusFilter,
|
||||
|
||||
@@ -21,8 +21,8 @@ import (
|
||||
"github.com/netbirdio/netbird/client/internal"
|
||||
"github.com/netbirdio/netbird/client/internal/peer"
|
||||
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
||||
"github.com/netbirdio/netbird/client/proto"
|
||||
nbnet "github.com/netbirdio/netbird/client/net"
|
||||
"github.com/netbirdio/netbird/client/proto"
|
||||
"github.com/netbirdio/netbird/client/server"
|
||||
"github.com/netbirdio/netbird/client/system"
|
||||
"github.com/netbirdio/netbird/shared/management/domain"
|
||||
@@ -626,7 +626,7 @@ func setupLoginRequest(providedSetupKey string, customDNSAddressConverted []byte
|
||||
NatExternalIPs: natExternalIPs,
|
||||
CleanNATExternalIPs: natExternalIPs != nil && len(natExternalIPs) == 0,
|
||||
CustomDNSAddress: customDNSAddressConverted,
|
||||
IsUnixDesktopClient: isUnixRunningDesktop(),
|
||||
IsUnixDesktopClient: util.HasGraphicalSession(),
|
||||
Hostname: hostName,
|
||||
ExtraIFaceBlacklist: extraIFaceBlackList,
|
||||
DnsLabels: dnsLabels,
|
||||
|
||||
@@ -21,7 +21,7 @@ import (
|
||||
"github.com/netbirdio/netbird/client/internal/auth"
|
||||
"github.com/netbirdio/netbird/client/internal/peer"
|
||||
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
||||
sshcommon "github.com/netbirdio/netbird/client/ssh"
|
||||
nbssh "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"
|
||||
@@ -521,12 +521,7 @@ func (c *Client) VerifySSHHostKey(peerAddress string, key []byte) error {
|
||||
return err
|
||||
}
|
||||
|
||||
storedKey, found := engine.GetPeerSSHKey(peerAddress)
|
||||
if !found {
|
||||
return sshcommon.ErrPeerNotFound
|
||||
}
|
||||
|
||||
return sshcommon.VerifyHostKey(storedKey, key, peerAddress)
|
||||
return nbssh.PeerKeyLookup(engine.GetPeerSSHKey).VerifySSHHostKey(peerAddress, key)
|
||||
}
|
||||
|
||||
// SetPerformance retunes a running Client. Only PreallocatedBuffersPerPool
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
// 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.
|
||||
// rules into them. Trusting the interface makes firewalld itself add the
|
||||
// accept rules to its own chains instead.
|
||||
package firewalld
|
||||
|
||||
// TrustedZone is the firewalld zone name used for interfaces whose traffic
|
||||
|
||||
@@ -42,6 +42,7 @@ type aclManager struct {
|
||||
optionalEntries map[string][]entry
|
||||
ipsetStore *ipsetStore
|
||||
v6 bool
|
||||
ipsetSupported bool
|
||||
|
||||
stateManager *statemanager.Manager
|
||||
}
|
||||
@@ -60,6 +61,8 @@ func newAclManager(iptablesClient *iptables.IPTables, wgIface iFaceMapper) (*acl
|
||||
func (m *aclManager) init(stateManager *statemanager.Manager) error {
|
||||
m.stateManager = stateManager
|
||||
|
||||
m.ipsetSupported = m.probeIPSetSupport()
|
||||
|
||||
m.seedInitialEntries()
|
||||
m.seedInitialOptionalEntries()
|
||||
|
||||
@@ -91,6 +94,12 @@ func (m *aclManager) AddPeerFiltering(
|
||||
if m.v6 && ipsetName != "" {
|
||||
ipsetName += "-v6"
|
||||
}
|
||||
// When the kernel lacks the required ipset hash module, fall back to
|
||||
// per-IP iptables rules (pre-0.68 behavior) so ACLs keep working instead
|
||||
// of silently leaving the chain empty.
|
||||
if ipsetName != "" && !m.ipsetSupported {
|
||||
ipsetName = ""
|
||||
}
|
||||
proto := protoForFamily(protocol, m.v6)
|
||||
specs := filterRuleSpecs(ip, proto, sPort, dPort, action, ipsetName)
|
||||
|
||||
@@ -498,6 +507,40 @@ func transformIPsetName(ipsetName string, sPort, dPort *firewall.Port, action fi
|
||||
}
|
||||
}
|
||||
|
||||
// probeIPSetSupport checks whether the kernel can create the ipset type used for
|
||||
// ACL rules. On kernels lacking the required ipset hash module, ipset creation
|
||||
// fails (e.g. "invalid argument"), which would otherwise leave the ACL chain
|
||||
// empty and silently drop all policy-permitted inbound traffic. When unsupported,
|
||||
// the manager falls back to per-IP iptables rules.
|
||||
func (m *aclManager) probeIPSetSupport() bool {
|
||||
// Use a unique name so concurrent processes don't collide and we only ever
|
||||
// destroy the set we created ourselves. ipset names are limited to 31 chars,
|
||||
// so use a short random suffix.
|
||||
probeName := "nb-probe-" + uuid.New().String()[:8]
|
||||
|
||||
opts := ipset.CreateOptions{
|
||||
Replace: true,
|
||||
}
|
||||
if m.v6 {
|
||||
opts.Family = ipset.FamilyIPV6
|
||||
}
|
||||
|
||||
if err := ipset.Create(probeName, ipset.TypeHashNet, opts); err != nil {
|
||||
log.Warnf("ipset is not available (failed to create probe set: %v); "+
|
||||
"falling back to per-IP iptables ACL rules. Ensure the kernel provides "+
|
||||
"the ipset hash:net module (ip_set_hash_net) for better performance with large rule sets", err)
|
||||
return false
|
||||
}
|
||||
|
||||
defer func() {
|
||||
if err := ipset.Destroy(probeName); err != nil {
|
||||
log.Debugf("destroy ipset probe set %q: %v", probeName, err)
|
||||
}
|
||||
}()
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
func (m *aclManager) createIPSet(name string) error {
|
||||
opts := ipset.CreateOptions{
|
||||
Replace: true,
|
||||
|
||||
240
client/firewall/iptables/dnat_refcount_linux_test.go
Normal file
240
client/firewall/iptables/dnat_refcount_linux_test.go
Normal file
@@ -0,0 +1,240 @@
|
||||
//go:build privileged
|
||||
|
||||
package iptables
|
||||
|
||||
import (
|
||||
"net/netip"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
fw "github.com/netbirdio/netbird/client/firewall/manager"
|
||||
"github.com/netbirdio/netbird/client/iface"
|
||||
"github.com/netbirdio/netbird/client/iface/wgaddr"
|
||||
)
|
||||
|
||||
func iptRefcountIfaceV4() *iFaceMock {
|
||||
return &iFaceMock{
|
||||
NameFunc: func() string { return "wt-refcount" },
|
||||
AddressFunc: func() wgaddr.Address {
|
||||
return wgaddr.Address{
|
||||
IP: netip.MustParseAddr("10.20.0.1"),
|
||||
Network: netip.MustParsePrefix("10.20.0.0/24"),
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func iptRefcountIfaceDual() *iFaceMock {
|
||||
return &iFaceMock{
|
||||
NameFunc: func() string { return "wt-refcount" },
|
||||
AddressFunc: func() wgaddr.Address {
|
||||
return wgaddr.Address{
|
||||
IP: netip.MustParseAddr("10.20.0.1"),
|
||||
Network: netip.MustParsePrefix("10.20.0.0/24"),
|
||||
IPv6: netip.MustParseAddr("fd00::1"),
|
||||
IPv6Net: netip.MustParsePrefix("fd00::/64"),
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func newIptRefcountManager(t *testing.T, dual bool) *Manager {
|
||||
t.Helper()
|
||||
var ifMock *iFaceMock
|
||||
if dual {
|
||||
ifMock = iptRefcountIfaceDual()
|
||||
} else {
|
||||
ifMock = iptRefcountIfaceV4()
|
||||
}
|
||||
m, err := Create(ifMock, iface.DefaultMTU)
|
||||
require.NoError(t, err, "create manager")
|
||||
require.NoError(t, m.Init(nil), "init manager")
|
||||
t.Cleanup(func() {
|
||||
require.NoError(t, m.Close(nil), "close manager")
|
||||
})
|
||||
return m
|
||||
}
|
||||
|
||||
func iptDnatV4(port uint16) fw.ForwardRule {
|
||||
return fw.ForwardRule{
|
||||
Protocol: fw.ProtocolTCP,
|
||||
DestinationPort: fw.Port{Values: []uint16{port}},
|
||||
TranslatedAddress: netip.MustParseAddr("10.20.0.2"),
|
||||
TranslatedPort: fw.Port{Values: []uint16{80}},
|
||||
}
|
||||
}
|
||||
|
||||
func iptDnatV6(port uint16) fw.ForwardRule {
|
||||
return fw.ForwardRule{
|
||||
Protocol: fw.ProtocolTCP,
|
||||
DestinationPort: fw.Port{Values: []uint16{port}},
|
||||
TranslatedAddress: netip.MustParseAddr("fd00::2"),
|
||||
TranslatedPort: fw.Port{Values: []uint16{80}},
|
||||
}
|
||||
}
|
||||
|
||||
// TestIptablesRouting_RepeatedEnableSingleReference verifies that EnableRouting
|
||||
// (called on every network-map update) holds at most one reference per family
|
||||
// and a single DisableRouting drops both back to zero.
|
||||
func TestIptablesRouting_RepeatedEnableSingleReference(t *testing.T) {
|
||||
m := newIptRefcountManager(t, true)
|
||||
state := m.router.ipFwdState
|
||||
|
||||
require.NoError(t, m.EnableRouting(), "first enable")
|
||||
require.NoError(t, m.EnableRouting(), "second enable")
|
||||
require.NoError(t, m.EnableRouting(), "third enable")
|
||||
v4, v6 := state.Counts()
|
||||
assert.Equal(t, 1, v4, "repeated enable holds a single v4 reference")
|
||||
assert.Equal(t, 1, v6, "repeated enable holds a single v6 reference")
|
||||
|
||||
require.NoError(t, m.DisableRouting(), "disable")
|
||||
v4, v6 = state.Counts()
|
||||
assert.Equal(t, 0, v4, "single disable releases the v4 reference")
|
||||
assert.Equal(t, 0, v6, "single disable releases the v6 reference")
|
||||
}
|
||||
|
||||
// TestIptablesRouting_DisableKeepsDNATReference verifies that an unpaired
|
||||
// DisableRouting does not release references held by active DNAT rules.
|
||||
func TestIptablesRouting_DisableKeepsDNATReference(t *testing.T) {
|
||||
m := newIptRefcountManager(t, true)
|
||||
state := m.router.ipFwdState
|
||||
|
||||
r1, err := m.AddDNATRule(iptDnatV6(9095))
|
||||
require.NoError(t, err, "add v6 dnat")
|
||||
|
||||
require.NoError(t, m.DisableRouting(), "unpaired disable")
|
||||
_, v6 := state.Counts()
|
||||
assert.Equal(t, 1, v6, "DNAT-held reference survives unpaired DisableRouting")
|
||||
|
||||
require.NoError(t, m.DeleteDNATRule(r1), "delete v6 dnat")
|
||||
_, v6 = state.Counts()
|
||||
assert.Equal(t, 0, v6, "delete releases the DNAT reference")
|
||||
}
|
||||
|
||||
// TestIptablesDNAT_RefcountBalancedV4 covers a Balanced Add/Delete pair on v4.
|
||||
func TestIptablesDNAT_RefcountBalancedV4(t *testing.T) {
|
||||
m := newIptRefcountManager(t, false)
|
||||
state := m.router.ipFwdState
|
||||
|
||||
r1, err := m.AddDNATRule(iptDnatV4(7081))
|
||||
require.NoError(t, err, "add v4 dnat 1")
|
||||
v4, v6 := state.Counts()
|
||||
assert.Equal(t, 1, v4, "v4 refcount after first add")
|
||||
assert.Equal(t, 0, v6, "v6 refcount unchanged")
|
||||
|
||||
r2, err := m.AddDNATRule(iptDnatV4(7082))
|
||||
require.NoError(t, err, "add v4 dnat 2")
|
||||
v4, v6 = state.Counts()
|
||||
assert.Equal(t, 2, v4, "v4 refcount after second add")
|
||||
assert.Equal(t, 0, v6, "v6 refcount unchanged")
|
||||
|
||||
require.NoError(t, m.DeleteDNATRule(r1))
|
||||
v4, v6 = state.Counts()
|
||||
assert.Equal(t, 1, v4, "v4 refcount after first delete")
|
||||
assert.Equal(t, 0, v6, "v6 refcount unchanged")
|
||||
|
||||
require.NoError(t, m.DeleteDNATRule(r2))
|
||||
v4, v6 = state.Counts()
|
||||
assert.Equal(t, 0, v4, "v4 refcount after second delete")
|
||||
assert.Equal(t, 0, v6, "v6 refcount unchanged")
|
||||
}
|
||||
|
||||
// TestIptablesDNAT_RefcountBalancedV6 checks the v6 path increments v6 only and
|
||||
// decrements back to zero.
|
||||
func TestIptablesDNAT_RefcountBalancedV6(t *testing.T) {
|
||||
m := newIptRefcountManager(t, true)
|
||||
require.NotNil(t, m.router6, "v6 router")
|
||||
require.Same(t, m.router.ipFwdState, m.router6.ipFwdState, "shared state")
|
||||
state := m.router.ipFwdState
|
||||
|
||||
r1, err := m.AddDNATRule(iptDnatV6(9081))
|
||||
require.NoError(t, err, "add v6 dnat 1")
|
||||
v4, v6 := state.Counts()
|
||||
assert.Equal(t, 0, v4)
|
||||
assert.Equal(t, 1, v6, "v6 refcount after first add")
|
||||
|
||||
r2, err := m.AddDNATRule(iptDnatV6(9082))
|
||||
require.NoError(t, err, "add v6 dnat 2")
|
||||
v4, v6 = state.Counts()
|
||||
assert.Equal(t, 0, v4, "v4 refcount unchanged")
|
||||
assert.Equal(t, 2, v6, "v6 refcount after second add")
|
||||
|
||||
require.NoError(t, m.DeleteDNATRule(r1))
|
||||
v4, v6 = state.Counts()
|
||||
assert.Equal(t, 0, v4, "v4 refcount unchanged")
|
||||
assert.Equal(t, 1, v6, "v6 refcount after first delete")
|
||||
|
||||
require.NoError(t, m.DeleteDNATRule(r2))
|
||||
v4, v6 = state.Counts()
|
||||
assert.Equal(t, 0, v4)
|
||||
assert.Equal(t, 0, v6, "v6 refcount after second delete")
|
||||
}
|
||||
|
||||
// TestIptablesDNAT_DuplicateAddNoLeak verifies the duplicate-rule path returns
|
||||
// without bumping the refcount.
|
||||
func TestIptablesDNAT_DuplicateAddNoLeak(t *testing.T) {
|
||||
m := newIptRefcountManager(t, true)
|
||||
state := m.router.ipFwdState
|
||||
|
||||
rule := iptDnatV4(7083)
|
||||
r1, err := m.AddDNATRule(rule)
|
||||
require.NoError(t, err)
|
||||
v4, _ := state.Counts()
|
||||
assert.Equal(t, 1, v4)
|
||||
|
||||
_, err = m.AddDNATRule(rule)
|
||||
require.NoError(t, err, "duplicate add")
|
||||
v4, _ = state.Counts()
|
||||
assert.Equal(t, 1, v4, "duplicate add must not increment")
|
||||
|
||||
require.NoError(t, m.DeleteDNATRule(r1))
|
||||
v4, _ = state.Counts()
|
||||
assert.Equal(t, 0, v4, "single delete must drop to zero")
|
||||
}
|
||||
|
||||
// TestIptablesDNAT_DeleteMissingNoUnderflow verifies Delete on an unknown rule
|
||||
// neither errors nor releases the refcount.
|
||||
func TestIptablesDNAT_DeleteMissingNoUnderflow(t *testing.T) {
|
||||
m := newIptRefcountManager(t, true)
|
||||
state := m.router.ipFwdState
|
||||
|
||||
phantom := iptDnatV4(7099)
|
||||
require.NoError(t, m.DeleteDNATRule(&phantom), "delete missing v4")
|
||||
v4, v6 := state.Counts()
|
||||
assert.Equal(t, 0, v4)
|
||||
assert.Equal(t, 0, v6)
|
||||
|
||||
phantom6 := iptDnatV6(9099)
|
||||
require.NoError(t, m.DeleteDNATRule(&phantom6), "delete missing v6")
|
||||
v4, v6 = state.Counts()
|
||||
assert.Equal(t, 0, v4)
|
||||
assert.Equal(t, 0, v6)
|
||||
|
||||
r1, err := m.AddDNATRule(iptDnatV4(7100))
|
||||
require.NoError(t, err)
|
||||
v4, _ = state.Counts()
|
||||
assert.Equal(t, 1, v4, "real add still increments after phantom delete")
|
||||
require.NoError(t, m.DeleteDNATRule(r1))
|
||||
}
|
||||
|
||||
// TestIptablesDNAT_DoubleDeleteNoUnderflow verifies a second Delete on the same
|
||||
// rule is a no-op.
|
||||
func TestIptablesDNAT_DoubleDeleteNoUnderflow(t *testing.T) {
|
||||
m := newIptRefcountManager(t, true)
|
||||
state := m.router.ipFwdState
|
||||
|
||||
r1, err := m.AddDNATRule(iptDnatV6(9083))
|
||||
require.NoError(t, err)
|
||||
_, v6 := state.Counts()
|
||||
assert.Equal(t, 1, v6)
|
||||
|
||||
require.NoError(t, m.DeleteDNATRule(r1), "first delete")
|
||||
_, v6 = state.Counts()
|
||||
assert.Equal(t, 0, v6)
|
||||
|
||||
require.NoError(t, m.DeleteDNATRule(r1), "second delete must be no-op")
|
||||
_, v6 = state.Counts()
|
||||
assert.Equal(t, 0, v6, "double delete must not underflow")
|
||||
}
|
||||
@@ -89,7 +89,7 @@ func (m *Manager) createIPv6Components(wgIface iFaceMapper, mtu uint16) error {
|
||||
}
|
||||
|
||||
// Share the same IP forwarding state with the v4 router, since
|
||||
// EnableIPForwarding controls both v4 and v6 sysctls.
|
||||
// Forwarding refcounter is per-family but shared between v4 and v6 routers.
|
||||
m.router6.ipFwdState = m.router.ipFwdState
|
||||
|
||||
m.aclMgr6, err = newAclManager(ip6Client, wgIface)
|
||||
@@ -402,17 +402,12 @@ func (m *Manager) SetLogLevel(log.Level) {
|
||||
}
|
||||
|
||||
func (m *Manager) EnableRouting() error {
|
||||
if err := m.router.ipFwdState.RequestForwarding(); err != nil {
|
||||
return fmt.Errorf("enable IP forwarding: %w", err)
|
||||
}
|
||||
return nil
|
||||
// v6 only when the overlay actually has v6.
|
||||
return m.router.ipFwdState.RequestRouting(m.router6 != nil)
|
||||
}
|
||||
|
||||
func (m *Manager) DisableRouting() error {
|
||||
if err := m.router.ipFwdState.ReleaseForwarding(); err != nil {
|
||||
return fmt.Errorf("disable IP forwarding: %w", err)
|
||||
}
|
||||
return nil
|
||||
return m.router.ipFwdState.ReleaseRouting()
|
||||
}
|
||||
|
||||
// AddDNATRule adds a DNAT rule
|
||||
|
||||
@@ -291,3 +291,40 @@ func TestIptablesCreatePerformance(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestIptablesACLIPSetFallback verifies that when the kernel lacks ipset support,
|
||||
// the ACL manager falls back to per-IP iptables rules (-s <ip>) instead of
|
||||
// silently leaving the chain empty. See discussion #6125.
|
||||
func TestIptablesACLIPSetFallback(t *testing.T) {
|
||||
ipv4Client, err := iptables.NewWithProtocol(iptables.ProtocolIPv4)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Use Create()/Init() so the router-owned chains (chainRTFWDIN/OUT) are
|
||||
// created before the ACL manager's createDefaultChains() references them.
|
||||
manager, err := Create(ifaceMock, iface.DefaultMTU)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, manager.Init(nil))
|
||||
|
||||
aclMgr := manager.aclMgr
|
||||
// Simulate a kernel without the ipset hash module.
|
||||
aclMgr.ipsetSupported = false
|
||||
|
||||
defer func() {
|
||||
require.NoError(t, manager.Close(nil))
|
||||
}()
|
||||
|
||||
ip := netip.MustParseAddr("10.20.0.42")
|
||||
port := &fw.Port{Values: []uint16{22}}
|
||||
|
||||
rules, err := aclMgr.AddPeerFiltering(nil, ip.AsSlice(), "tcp", nil, port, fw.ActionAccept, "nb0000001")
|
||||
require.NoError(t, err, "AddPeerFiltering should succeed via fallback")
|
||||
require.NotEmpty(t, rules)
|
||||
|
||||
rule := rules[0].(*Rule)
|
||||
require.Empty(t, rule.ipsetName, "fallback rule must not reference an ipset")
|
||||
require.Contains(t, strings.Join(rule.specs, " "), "-s 10.20.0.42", "fallback rule must match by source IP")
|
||||
require.NotContains(t, strings.Join(rule.specs, " "), "--match-set", "fallback rule must not use ipset matching")
|
||||
|
||||
// The rule must actually be present in the ACL chain (not silently dropped).
|
||||
checkRuleSpecs(t, ipv4Client, rule.chain, true, rule.specs...)
|
||||
}
|
||||
|
||||
@@ -102,7 +102,7 @@ func newRouter(iptablesClient *iptables.IPTables, wgIface iFaceMapper, mtu uint1
|
||||
wgIface: wgIface,
|
||||
mtu: mtu,
|
||||
v6: iptablesClient.Proto() == iptables.ProtocolIPv6,
|
||||
ipFwdState: ipfwdstate.NewIPForwardingState(),
|
||||
ipFwdState: ipfwdstate.NewIPForwardingState(wgIface.Name()),
|
||||
}
|
||||
|
||||
r.ipsetCounter = refcounter.New(
|
||||
@@ -770,10 +770,6 @@ func (r *router) updateState() {
|
||||
}
|
||||
|
||||
func (r *router) AddDNATRule(rule firewall.ForwardRule) (firewall.Rule, error) {
|
||||
if err := r.ipFwdState.RequestForwarding(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ruleKey := rule.ID()
|
||||
if _, exists := r.rules[ruleKey+dnatSuffix]; exists {
|
||||
return rule, nil
|
||||
@@ -840,18 +836,34 @@ func (r *router) AddDNATRule(rule firewall.ForwardRule) (firewall.Rule, error) {
|
||||
|
||||
for key, ruleInfo := range rules {
|
||||
if err := r.iptablesClient.Append(ruleInfo.table, ruleInfo.chain, ruleInfo.rule...); err != nil {
|
||||
if rollbackErr := r.rollbackRules(rules); rollbackErr != nil {
|
||||
log.Errorf("rollback failed: %v", rollbackErr)
|
||||
}
|
||||
r.cleanupFailedDNATAdd(rules)
|
||||
return nil, fmt.Errorf("add rule %s: %w", key, err)
|
||||
}
|
||||
r.rules[key] = ruleInfo.rule
|
||||
}
|
||||
|
||||
if err := r.ipFwdState.RequestForwarding(r.v6); err != nil {
|
||||
r.cleanupFailedDNATAdd(rules)
|
||||
return nil, fmt.Errorf("enable forwarding: %w", err)
|
||||
}
|
||||
|
||||
r.updateState()
|
||||
return rule, nil
|
||||
}
|
||||
|
||||
// cleanupFailedDNATAdd removes the bookkeeping written by a partially applied
|
||||
// AddDNATRule before rolling back the kernel rules, so no entries remain that
|
||||
// never got a forwarding refcount. rollbackRules re-adds entries it failed to
|
||||
// remove from the kernel.
|
||||
func (r *router) cleanupFailedDNATAdd(rules map[string]ruleInfo) {
|
||||
for key := range rules {
|
||||
delete(r.rules, key)
|
||||
}
|
||||
if err := r.rollbackRules(rules); err != nil {
|
||||
log.Errorf("rollback failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (r *router) rollbackRules(rules map[string]ruleInfo) error {
|
||||
var merr *multierror.Error
|
||||
for key, ruleInfo := range rules {
|
||||
@@ -868,32 +880,47 @@ func (r *router) rollbackRules(rules map[string]ruleInfo) error {
|
||||
}
|
||||
|
||||
func (r *router) DeleteDNATRule(rule firewall.Rule) error {
|
||||
if err := r.ipFwdState.ReleaseForwarding(); err != nil {
|
||||
log.Errorf("%v", err)
|
||||
}
|
||||
|
||||
ruleKey := rule.ID()
|
||||
|
||||
_, hadDNAT := r.rules[ruleKey+dnatSuffix]
|
||||
_, hadSNAT := r.rules[ruleKey+snatSuffix]
|
||||
_, hadFWD := r.rules[ruleKey+fwdSuffix]
|
||||
if !hadDNAT && !hadSNAT && !hadFWD {
|
||||
return nil
|
||||
}
|
||||
|
||||
var merr *multierror.Error
|
||||
if dnatRule, exists := r.rules[ruleKey+dnatSuffix]; exists {
|
||||
if err := r.iptablesClient.Delete(tableNat, chainRTRDR, dnatRule...); err != nil {
|
||||
merr = multierror.Append(merr, fmt.Errorf("delete DNAT rule: %w", err))
|
||||
} else {
|
||||
delete(r.rules, ruleKey+dnatSuffix)
|
||||
}
|
||||
delete(r.rules, ruleKey+dnatSuffix)
|
||||
}
|
||||
|
||||
if snatRule, exists := r.rules[ruleKey+snatSuffix]; exists {
|
||||
if err := r.iptablesClient.Delete(tableNat, chainRTNAT, snatRule...); err != nil {
|
||||
merr = multierror.Append(merr, fmt.Errorf("delete SNAT rule: %w", err))
|
||||
} else {
|
||||
delete(r.rules, ruleKey+snatSuffix)
|
||||
}
|
||||
delete(r.rules, ruleKey+snatSuffix)
|
||||
}
|
||||
|
||||
if fwdRule, exists := r.rules[ruleKey+fwdSuffix]; exists {
|
||||
if err := r.iptablesClient.Delete(tableFilter, chainRTFWDOUT, fwdRule...); err != nil {
|
||||
merr = multierror.Append(merr, fmt.Errorf("delete forward rule: %w", err))
|
||||
} else {
|
||||
delete(r.rules, ruleKey+fwdSuffix)
|
||||
}
|
||||
}
|
||||
|
||||
// Release the refcount only once all rules are gone from the kernel. On
|
||||
// partial failure the failed entries stay in r.rules so a retry can remove
|
||||
// them and release then.
|
||||
if merr == nil {
|
||||
if err := r.ipFwdState.ReleaseForwarding(r.v6); err != nil {
|
||||
log.Errorf("%v", err)
|
||||
}
|
||||
delete(r.rules, ruleKey+fwdSuffix)
|
||||
}
|
||||
|
||||
r.updateState()
|
||||
|
||||
249
client/firewall/nftables/dnat_refcount_linux_test.go
Normal file
249
client/firewall/nftables/dnat_refcount_linux_test.go
Normal file
@@ -0,0 +1,249 @@
|
||||
//go:build privileged
|
||||
|
||||
package nftables
|
||||
|
||||
import (
|
||||
"net/netip"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
fw "github.com/netbirdio/netbird/client/firewall/manager"
|
||||
"github.com/netbirdio/netbird/client/iface"
|
||||
"github.com/netbirdio/netbird/client/iface/wgaddr"
|
||||
)
|
||||
|
||||
func nftRefcountIfaceV4() *iFaceMock {
|
||||
return &iFaceMock{
|
||||
NameFunc: func() string { return "wt-refcount" },
|
||||
AddressFunc: func() wgaddr.Address {
|
||||
return wgaddr.Address{
|
||||
IP: netip.MustParseAddr("100.96.0.1"),
|
||||
Network: netip.MustParsePrefix("100.96.0.0/16"),
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func nftRefcountIfaceDual() *iFaceMock {
|
||||
return &iFaceMock{
|
||||
NameFunc: func() string { return "wt-refcount" },
|
||||
AddressFunc: func() wgaddr.Address {
|
||||
return wgaddr.Address{
|
||||
IP: netip.MustParseAddr("100.96.0.1"),
|
||||
Network: netip.MustParsePrefix("100.96.0.0/16"),
|
||||
IPv6: netip.MustParseAddr("fd00::1"),
|
||||
IPv6Net: netip.MustParsePrefix("fd00::/64"),
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func newNftRefcountManager(t *testing.T, dual bool) *Manager {
|
||||
t.Helper()
|
||||
if check() != NFTABLES {
|
||||
t.Skip("nftables not supported on this system")
|
||||
}
|
||||
var ifMock *iFaceMock
|
||||
if dual {
|
||||
ifMock = nftRefcountIfaceDual()
|
||||
} else {
|
||||
ifMock = nftRefcountIfaceV4()
|
||||
}
|
||||
m, err := Create(ifMock, iface.DefaultMTU)
|
||||
require.NoError(t, err, "create manager")
|
||||
require.NoError(t, m.Init(nil), "init manager")
|
||||
t.Cleanup(func() {
|
||||
require.NoError(t, m.Close(nil), "close manager")
|
||||
})
|
||||
return m
|
||||
}
|
||||
|
||||
func dnatV4(port uint16) fw.ForwardRule {
|
||||
return fw.ForwardRule{
|
||||
Protocol: fw.ProtocolTCP,
|
||||
DestinationPort: fw.Port{Values: []uint16{port}},
|
||||
TranslatedAddress: netip.MustParseAddr("100.96.0.2"),
|
||||
TranslatedPort: fw.Port{Values: []uint16{80}},
|
||||
}
|
||||
}
|
||||
|
||||
func dnatV6(port uint16) fw.ForwardRule {
|
||||
return fw.ForwardRule{
|
||||
Protocol: fw.ProtocolTCP,
|
||||
DestinationPort: fw.Port{Values: []uint16{port}},
|
||||
TranslatedAddress: netip.MustParseAddr("fd00::2"),
|
||||
TranslatedPort: fw.Port{Values: []uint16{80}},
|
||||
}
|
||||
}
|
||||
|
||||
// TestNftablesDNAT_RefcountBalancedV4 verifies that Add/Delete pairs leave the
|
||||
// v4 refcount at zero.
|
||||
func TestNftablesDNAT_RefcountBalancedV4(t *testing.T) {
|
||||
m := newNftRefcountManager(t, false)
|
||||
state := m.router.ipFwdState
|
||||
|
||||
r1, err := m.AddDNATRule(dnatV4(8081))
|
||||
require.NoError(t, err, "add v4 dnat 1")
|
||||
v4, v6 := state.Counts()
|
||||
assert.Equal(t, 1, v4, "v4 refcount after first add")
|
||||
assert.Equal(t, 0, v6, "v6 refcount unchanged")
|
||||
|
||||
r2, err := m.AddDNATRule(dnatV4(8082))
|
||||
require.NoError(t, err, "add v4 dnat 2")
|
||||
v4, v6 = state.Counts()
|
||||
assert.Equal(t, 2, v4, "v4 refcount after second add")
|
||||
assert.Equal(t, 0, v6, "v6 refcount unchanged")
|
||||
|
||||
require.NoError(t, m.DeleteDNATRule(r1), "delete v4 dnat 1")
|
||||
v4, v6 = state.Counts()
|
||||
assert.Equal(t, 1, v4, "v4 refcount after first delete")
|
||||
assert.Equal(t, 0, v6, "v6 refcount unchanged")
|
||||
|
||||
require.NoError(t, m.DeleteDNATRule(r2), "delete v4 dnat 2")
|
||||
v4, v6 = state.Counts()
|
||||
assert.Equal(t, 0, v4, "v4 refcount after second delete")
|
||||
assert.Equal(t, 0, v6, "v6 refcount unchanged")
|
||||
}
|
||||
|
||||
// TestNftablesDNAT_RefcountBalancedV6 verifies the v6 path increments v6 only
|
||||
// and decrements back to zero on Delete.
|
||||
func TestNftablesDNAT_RefcountBalancedV6(t *testing.T) {
|
||||
m := newNftRefcountManager(t, true)
|
||||
require.NotNil(t, m.router6, "v6 router")
|
||||
require.Same(t, m.router.ipFwdState, m.router6.ipFwdState, "shared state")
|
||||
state := m.router.ipFwdState
|
||||
|
||||
r1, err := m.AddDNATRule(dnatV6(9091))
|
||||
require.NoError(t, err, "add v6 dnat 1")
|
||||
v4, v6 := state.Counts()
|
||||
assert.Equal(t, 0, v4, "v4 refcount unchanged")
|
||||
assert.Equal(t, 1, v6, "v6 refcount after first add")
|
||||
|
||||
r2, err := m.AddDNATRule(dnatV6(9092))
|
||||
require.NoError(t, err, "add v6 dnat 2")
|
||||
v4, v6 = state.Counts()
|
||||
assert.Equal(t, 0, v4)
|
||||
assert.Equal(t, 2, v6, "v6 refcount after second add")
|
||||
|
||||
require.NoError(t, m.DeleteDNATRule(r1), "delete v6 dnat 1")
|
||||
v4, v6 = state.Counts()
|
||||
assert.Equal(t, 0, v4, "v4 refcount unchanged")
|
||||
assert.Equal(t, 1, v6, "v6 refcount after first delete")
|
||||
|
||||
require.NoError(t, m.DeleteDNATRule(r2), "delete v6 dnat 2")
|
||||
v4, v6 = state.Counts()
|
||||
assert.Equal(t, 0, v4)
|
||||
assert.Equal(t, 0, v6, "v6 refcount after second delete")
|
||||
}
|
||||
|
||||
// TestNftablesDNAT_DuplicateAddNoLeak verifies that a duplicate Add (same
|
||||
// ForwardRule) does not double-increment the refcount.
|
||||
func TestNftablesDNAT_DuplicateAddNoLeak(t *testing.T) {
|
||||
m := newNftRefcountManager(t, true)
|
||||
state := m.router.ipFwdState
|
||||
|
||||
rule := dnatV4(8083)
|
||||
r1, err := m.AddDNATRule(rule)
|
||||
require.NoError(t, err, "add v4 dnat")
|
||||
v4, _ := state.Counts()
|
||||
assert.Equal(t, 1, v4)
|
||||
|
||||
// duplicate add: same rule ID, must be a no-op for the refcount.
|
||||
_, err = m.AddDNATRule(rule)
|
||||
require.NoError(t, err, "duplicate add")
|
||||
v4, _ = state.Counts()
|
||||
assert.Equal(t, 1, v4, "duplicate add must not increment")
|
||||
|
||||
require.NoError(t, m.DeleteDNATRule(r1), "delete v4 dnat")
|
||||
v4, _ = state.Counts()
|
||||
assert.Equal(t, 0, v4, "single delete must drop to zero")
|
||||
}
|
||||
|
||||
// TestNftablesDNAT_DeleteMissingNoUnderflow verifies deleting a rule that was
|
||||
// never added does not underflow the refcount.
|
||||
func TestNftablesDNAT_DeleteMissingNoUnderflow(t *testing.T) {
|
||||
m := newNftRefcountManager(t, true)
|
||||
state := m.router.ipFwdState
|
||||
|
||||
// Construct a Rule reference for something never added. The router stores
|
||||
// rules by ID(), and DeleteDNATRule looks them up in r.rules; a missing
|
||||
// entry must be a no-op rather than calling Release.
|
||||
phantom := dnatV4(8099)
|
||||
require.NoError(t, m.DeleteDNATRule(&phantom), "delete missing v4 dnat")
|
||||
v4, v6 := state.Counts()
|
||||
assert.Equal(t, 0, v4, "v4 refcount unaffected by missing delete")
|
||||
assert.Equal(t, 0, v6, "v6 refcount unaffected")
|
||||
|
||||
phantom6 := dnatV6(9099)
|
||||
require.NoError(t, m.DeleteDNATRule(&phantom6), "delete missing v6 dnat")
|
||||
v4, v6 = state.Counts()
|
||||
assert.Equal(t, 0, v4)
|
||||
assert.Equal(t, 0, v6, "v6 refcount unaffected by missing delete")
|
||||
|
||||
// And after a phantom delete, a real add still results in count=1.
|
||||
r1, err := m.AddDNATRule(dnatV4(8100))
|
||||
require.NoError(t, err, "add v4 dnat after phantom delete")
|
||||
v4, _ = state.Counts()
|
||||
assert.Equal(t, 1, v4, "real add still increments after phantom delete")
|
||||
require.NoError(t, m.DeleteDNATRule(r1))
|
||||
}
|
||||
|
||||
// TestNftablesRouting_RepeatedEnableSingleReference verifies that EnableRouting
|
||||
// (called on every network-map update) holds at most one reference per family
|
||||
// and a single DisableRouting drops both back to zero.
|
||||
func TestNftablesRouting_RepeatedEnableSingleReference(t *testing.T) {
|
||||
m := newNftRefcountManager(t, true)
|
||||
state := m.router.ipFwdState
|
||||
|
||||
require.NoError(t, m.EnableRouting(), "first enable")
|
||||
require.NoError(t, m.EnableRouting(), "second enable")
|
||||
require.NoError(t, m.EnableRouting(), "third enable")
|
||||
v4, v6 := state.Counts()
|
||||
assert.Equal(t, 1, v4, "repeated enable holds a single v4 reference")
|
||||
assert.Equal(t, 1, v6, "repeated enable holds a single v6 reference")
|
||||
|
||||
require.NoError(t, m.DisableRouting(), "disable")
|
||||
v4, v6 = state.Counts()
|
||||
assert.Equal(t, 0, v4, "single disable releases the v4 reference")
|
||||
assert.Equal(t, 0, v6, "single disable releases the v6 reference")
|
||||
}
|
||||
|
||||
// TestNftablesRouting_DisableKeepsDNATReference verifies that an unpaired
|
||||
// DisableRouting does not release references held by active DNAT rules.
|
||||
func TestNftablesRouting_DisableKeepsDNATReference(t *testing.T) {
|
||||
m := newNftRefcountManager(t, true)
|
||||
state := m.router.ipFwdState
|
||||
|
||||
r1, err := m.AddDNATRule(dnatV6(9095))
|
||||
require.NoError(t, err, "add v6 dnat")
|
||||
|
||||
require.NoError(t, m.DisableRouting(), "unpaired disable")
|
||||
_, v6 := state.Counts()
|
||||
assert.Equal(t, 1, v6, "DNAT-held reference survives unpaired DisableRouting")
|
||||
|
||||
require.NoError(t, m.DeleteDNATRule(r1), "delete v6 dnat")
|
||||
_, v6 = state.Counts()
|
||||
assert.Equal(t, 0, v6, "delete releases the DNAT reference")
|
||||
}
|
||||
|
||||
// TestNftablesDNAT_DoubleDeleteNoUnderflow verifies that deleting the same rule
|
||||
// twice does not underflow the refcount (the second delete is a no-op).
|
||||
func TestNftablesDNAT_DoubleDeleteNoUnderflow(t *testing.T) {
|
||||
m := newNftRefcountManager(t, true)
|
||||
state := m.router.ipFwdState
|
||||
|
||||
r1, err := m.AddDNATRule(dnatV6(9093))
|
||||
require.NoError(t, err)
|
||||
_, v6 := state.Counts()
|
||||
assert.Equal(t, 1, v6)
|
||||
|
||||
require.NoError(t, m.DeleteDNATRule(r1), "first delete")
|
||||
_, v6 = state.Counts()
|
||||
assert.Equal(t, 0, v6)
|
||||
|
||||
require.NoError(t, m.DeleteDNATRule(r1), "second delete must be no-op")
|
||||
_, v6 = state.Counts()
|
||||
assert.Equal(t, 0, v6, "double delete must not underflow")
|
||||
}
|
||||
@@ -105,8 +105,8 @@ func (m *Manager) createIPv6Components(tableName string, wgIface iFaceMapper, mt
|
||||
return fmt.Errorf("create v6 router: %w", err)
|
||||
}
|
||||
|
||||
// Share the same IP forwarding state with the v4 router, since
|
||||
// EnableIPForwarding controls both v4 and v6 sysctls.
|
||||
// Share the per-family forwarding refcounter with the v4 router so a v4
|
||||
// rule and a v6 rule against the same state machine cooperate cleanly.
|
||||
m.router6.ipFwdState = m.router.ipFwdState
|
||||
|
||||
m.aclManager6, err = newAclManager(workTable6, wgIface, chainNameRoutingFw)
|
||||
@@ -530,17 +530,12 @@ func (m *Manager) SetLogLevel(log.Level) {
|
||||
}
|
||||
|
||||
func (m *Manager) EnableRouting() error {
|
||||
if err := m.router.ipFwdState.RequestForwarding(); err != nil {
|
||||
return fmt.Errorf("enable IP forwarding: %w", err)
|
||||
}
|
||||
return nil
|
||||
// v6 only when the overlay actually has v6.
|
||||
return m.router.ipFwdState.RequestRouting(m.router6 != nil)
|
||||
}
|
||||
|
||||
func (m *Manager) DisableRouting() error {
|
||||
if err := m.router.ipFwdState.ReleaseForwarding(); err != nil {
|
||||
return fmt.Errorf("disable IP forwarding: %w", err)
|
||||
}
|
||||
return nil
|
||||
return m.router.ipFwdState.ReleaseRouting()
|
||||
}
|
||||
|
||||
// Flush rule/chain/set operations from the buffer
|
||||
|
||||
@@ -93,7 +93,7 @@ func newRouter(workTable *nftables.Table, wgIface iFaceMapper, mtu uint16) (*rou
|
||||
rules: make(map[string]*nftables.Rule),
|
||||
af: familyForAddr(workTable.Family == nftables.TableFamilyIPv4),
|
||||
wgIface: wgIface,
|
||||
ipFwdState: ipfwdstate.NewIPForwardingState(),
|
||||
ipFwdState: ipfwdstate.NewIPForwardingState(wgIface.Name()),
|
||||
mtu: mtu,
|
||||
}
|
||||
|
||||
@@ -1553,10 +1553,6 @@ func (r *router) refreshRulesMap() error {
|
||||
}
|
||||
|
||||
func (r *router) AddDNATRule(rule firewall.ForwardRule) (firewall.Rule, error) {
|
||||
if err := r.ipFwdState.RequestForwarding(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ruleKey := rule.ID()
|
||||
if _, exists := r.rules[ruleKey+dnatSuffix]; exists {
|
||||
return rule, nil
|
||||
@@ -1567,7 +1563,18 @@ func (r *router) AddDNATRule(rule firewall.ForwardRule) (firewall.Rule, error) {
|
||||
return nil, fmt.Errorf("convert protocol to number: %w", err)
|
||||
}
|
||||
|
||||
// Request forwarding before queueing rules: addDnatRedirect/addDnatMasq
|
||||
// buffer netlink messages on r.conn that the next caller's Flush would
|
||||
// commit if we returned without flushing them ourselves.
|
||||
v6 := r.af.tableFamily == nftables.TableFamilyIPv6
|
||||
if err := r.ipFwdState.RequestForwarding(v6); err != nil {
|
||||
return nil, fmt.Errorf("enable forwarding: %w", err)
|
||||
}
|
||||
|
||||
if err := r.addDnatRedirect(rule, protoNum, ruleKey); err != nil {
|
||||
if rerr := r.ipFwdState.ReleaseForwarding(v6); rerr != nil {
|
||||
log.Warnf("rollback forwarding refcount: %v", rerr)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -1579,6 +1586,11 @@ func (r *router) AddDNATRule(rule firewall.ForwardRule) (firewall.Rule, error) {
|
||||
// TODO: find chains with drop policies and add rules there
|
||||
|
||||
if err := r.conn.Flush(); err != nil {
|
||||
if rerr := r.ipFwdState.ReleaseForwarding(v6); rerr != nil {
|
||||
log.Warnf("rollback forwarding refcount: %v", rerr)
|
||||
}
|
||||
delete(r.rules, ruleKey+dnatSuffix)
|
||||
delete(r.rules, ruleKey+snatSuffix)
|
||||
return nil, fmt.Errorf("flush rules: %w", err)
|
||||
}
|
||||
|
||||
@@ -1781,16 +1793,18 @@ func (r *router) addDnatMasq(rule firewall.ForwardRule, protoNum uint8, ruleKey
|
||||
}
|
||||
|
||||
func (r *router) DeleteDNATRule(rule firewall.Rule) error {
|
||||
if err := r.ipFwdState.ReleaseForwarding(); err != nil {
|
||||
log.Errorf("%v", err)
|
||||
}
|
||||
|
||||
ruleKey := rule.ID()
|
||||
|
||||
if err := r.refreshRulesMap(); err != nil {
|
||||
return fmt.Errorf(refreshRulesMapError, err)
|
||||
}
|
||||
|
||||
_, hadDNAT := r.rules[ruleKey+dnatSuffix]
|
||||
_, hadSNAT := r.rules[ruleKey+snatSuffix]
|
||||
if !hadDNAT && !hadSNAT {
|
||||
return nil
|
||||
}
|
||||
|
||||
var merr *multierror.Error
|
||||
var needsFlush bool
|
||||
|
||||
@@ -1822,9 +1836,16 @@ func (r *router) DeleteDNATRule(rule firewall.Rule) error {
|
||||
}
|
||||
}
|
||||
|
||||
// Release the refcount only once the rules are gone from the kernel. On
|
||||
// failure (including the refreshRulesMap error above) the rules and their
|
||||
// map entries remain, keeping forwarding on until a retry removes them.
|
||||
if merr == nil {
|
||||
delete(r.rules, ruleKey+dnatSuffix)
|
||||
delete(r.rules, ruleKey+snatSuffix)
|
||||
|
||||
if err := r.ipFwdState.ReleaseForwarding(r.af.tableFamily == nftables.TableFamilyIPv6); err != nil {
|
||||
log.Errorf("%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nberrors.FormatErrorOrNil(merr)
|
||||
|
||||
@@ -16,28 +16,47 @@ import (
|
||||
"google.golang.org/grpc"
|
||||
|
||||
nbnet "github.com/netbirdio/netbird/client/net"
|
||||
"github.com/netbirdio/netbird/client/netsweep"
|
||||
)
|
||||
|
||||
func WithCustomDialer(_ bool, _ string) grpc.DialOption {
|
||||
return grpc.WithContextDialer(dialContext)
|
||||
}
|
||||
|
||||
// WithSweeper dials like WithCustomDialer but registers connections and
|
||||
// dials with the sweeper. Append it after WithCustomDialer: gRPC applies
|
||||
// dial options in order, so the later context dialer wins.
|
||||
func WithSweeper(sweeper *netsweep.Sweeper) grpc.DialOption {
|
||||
return grpc.WithContextDialer(func(ctx context.Context, addr string) (net.Conn, error) {
|
||||
if runtime.GOOS == "linux" {
|
||||
currentUser, err := user.Current()
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.FailedPrecondition, "failed to get current user: %v", err)
|
||||
}
|
||||
dial := sweeper.StartDial(ctx)
|
||||
defer dial.Release()
|
||||
|
||||
// the custom dialer requires root permissions which are not required for use cases run as non-root
|
||||
if currentUser.Uid != "0" {
|
||||
log.Debug("Not running as root, using standard dialer")
|
||||
dialer := &net.Dialer{}
|
||||
return dialer.DialContext(ctx, "tcp", addr)
|
||||
}
|
||||
}
|
||||
|
||||
conn, err := nbnet.NewDialer().DialContext(ctx, "tcp", addr)
|
||||
conn, err := dialContext(dial.Ctx(), addr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("nbnet.NewDialer().DialContext: %w", err)
|
||||
return nil, err
|
||||
}
|
||||
return conn, nil
|
||||
return dial.WrapConn(conn)
|
||||
})
|
||||
}
|
||||
|
||||
func dialContext(ctx context.Context, addr string) (net.Conn, error) {
|
||||
if runtime.GOOS == "linux" {
|
||||
currentUser, err := user.Current()
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.FailedPrecondition, "failed to get current user: %v", err)
|
||||
}
|
||||
|
||||
// the custom dialer requires root permissions which are not required for use cases run as non-root
|
||||
if currentUser.Uid != "0" {
|
||||
log.Debug("Not running as root, using standard dialer")
|
||||
dialer := &net.Dialer{}
|
||||
return dialer.DialContext(ctx, "tcp", addr)
|
||||
}
|
||||
}
|
||||
|
||||
conn, err := nbnet.NewDialer().DialContext(ctx, "tcp", addr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("nbnet.NewDialer().DialContext: %w", err)
|
||||
}
|
||||
return conn, nil
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package grpc
|
||||
import (
|
||||
"google.golang.org/grpc"
|
||||
|
||||
"github.com/netbirdio/netbird/client/netsweep"
|
||||
"github.com/netbirdio/netbird/util/wsproxy/client"
|
||||
)
|
||||
|
||||
@@ -11,3 +12,8 @@ import (
|
||||
func WithCustomDialer(tlsEnabled bool, component string) grpc.DialOption {
|
||||
return client.WithWebSocketDialer(tlsEnabled, component)
|
||||
}
|
||||
|
||||
// WithSweeper is a no-op on WASM/JS: there is no network change signal.
|
||||
func WithSweeper(_ *netsweep.Sweeper) grpc.DialOption {
|
||||
return grpc.EmptyDialOption{}
|
||||
}
|
||||
|
||||
49
client/grpc/retry.go
Normal file
49
client/grpc/retry.go
Normal file
@@ -0,0 +1,49 @@
|
||||
package grpc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/cenkalti/backoff/v4"
|
||||
|
||||
"github.com/netbirdio/netbird/client/netstate"
|
||||
)
|
||||
|
||||
// Retry mirrors backoff.Retry, but the sleep between attempts also wakes on
|
||||
// OS network availability transitions: an operation cut down by a network
|
||||
// change retries the moment the network settles instead of sleeping through
|
||||
// the recovery. A nil netState never fires, leaving plain backoff.Retry
|
||||
// behavior.
|
||||
func Retry(ctx context.Context, operation backoff.Operation, bo backoff.BackOff, netState *netstate.State) error {
|
||||
bo.Reset()
|
||||
for {
|
||||
err := operation()
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var permanent *backoff.PermanentError
|
||||
if errors.As(err, &permanent) {
|
||||
return permanent.Err
|
||||
}
|
||||
|
||||
next := bo.NextBackOff()
|
||||
if next == backoff.Stop {
|
||||
if cerr := ctx.Err(); cerr != nil {
|
||||
return cerr
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
timer := time.NewTimer(next)
|
||||
select {
|
||||
case <-timer.C:
|
||||
case <-netState.Changed():
|
||||
timer.Stop()
|
||||
case <-ctx.Done():
|
||||
timer.Stop()
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
}
|
||||
91
client/grpc/retry_test.go
Normal file
91
client/grpc/retry_test.go
Normal file
@@ -0,0 +1,91 @@
|
||||
package grpc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/cenkalti/backoff/v4"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/netbirdio/netbird/client/netstate"
|
||||
)
|
||||
|
||||
func TestRetryWakesOnNetworkChange(t *testing.T) {
|
||||
ns := netstate.New()
|
||||
attempts := 0
|
||||
operation := func() error {
|
||||
attempts++
|
||||
if attempts == 1 {
|
||||
return errors.New("cut by network change")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
go func() {
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
ns.Set(false)
|
||||
}()
|
||||
|
||||
start := time.Now()
|
||||
err := Retry(context.Background(), operation, backoff.NewConstantBackOff(time.Minute), ns)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 2, attempts)
|
||||
assert.Less(t, time.Since(start), time.Second, "the transition must cut the minute-long sleep short")
|
||||
}
|
||||
|
||||
func TestRetryPermanentError(t *testing.T) {
|
||||
sentinel := errors.New("permission denied")
|
||||
operation := func() error {
|
||||
return backoff.Permanent(sentinel)
|
||||
}
|
||||
|
||||
err := Retry(context.Background(), operation, backoff.NewConstantBackOff(time.Millisecond), nil)
|
||||
assert.ErrorIs(t, err, sentinel)
|
||||
}
|
||||
|
||||
func TestRetryNilNetState(t *testing.T) {
|
||||
attempts := 0
|
||||
operation := func() error {
|
||||
attempts++
|
||||
if attempts < 3 {
|
||||
return errors.New("transient")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
err := Retry(context.Background(), operation, backoff.NewConstantBackOff(time.Millisecond), nil)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 3, attempts)
|
||||
}
|
||||
|
||||
func TestRetryStops(t *testing.T) {
|
||||
failure := errors.New("still failing")
|
||||
operation := func() error {
|
||||
return failure
|
||||
}
|
||||
|
||||
err := Retry(context.Background(), operation, &backoff.StopBackOff{}, nil)
|
||||
assert.ErrorIs(t, err, failure)
|
||||
}
|
||||
|
||||
func TestRetryCtxCancelDuringSleep(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
operation := func() error {
|
||||
return errors.New("failing")
|
||||
}
|
||||
|
||||
go func() {
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
cancel()
|
||||
}()
|
||||
|
||||
start := time.Now()
|
||||
err := Retry(ctx, operation, backoff.NewConstantBackOff(time.Minute), netstate.New())
|
||||
|
||||
assert.ErrorIs(t, err, context.Canceled)
|
||||
assert.Less(t, time.Since(start), time.Second)
|
||||
}
|
||||
@@ -22,6 +22,16 @@ import (
|
||||
nbnet "github.com/netbirdio/netbird/client/net"
|
||||
)
|
||||
|
||||
const (
|
||||
// wgMsgTypeHandshakeInitiation is the lowest WireGuard message type.
|
||||
wgMsgTypeHandshakeInitiation uint32 = 1
|
||||
// wgMsgTypeTransport is the highest WireGuard message type.
|
||||
wgMsgTypeTransport uint32 = 4
|
||||
// wgMinMsgSize is the smallest WireGuard message: transport data with an empty
|
||||
// payload, which is what a keepalive is.
|
||||
wgMinMsgSize = 32
|
||||
)
|
||||
|
||||
type receiverCreator struct {
|
||||
iceBind *ICEBind
|
||||
}
|
||||
@@ -216,8 +226,15 @@ func (s *ICEBind) createReceiverFn(pc wgConn.BatchReader, conn *net.UDPConn, rxO
|
||||
for i := 0; i < numMsgs; i++ {
|
||||
msg := &(*msgs)[i]
|
||||
|
||||
// todo: handle err
|
||||
if ok, _ := s.filterOutStunMessages(msg.Buffers, msg.N, msg.Addr); ok {
|
||||
if ok, err := s.filterOutStunMessages(msg.Buffers, msg.N, msg.Addr); ok {
|
||||
if err != nil {
|
||||
log.Debugf("failed to handle STUN packet from %s: %v", msg.Addr, err)
|
||||
}
|
||||
// WireGuard reuses sizes and eps across reads and only skips a slot
|
||||
// whose size is below the minimum message size. Leaving a consumed
|
||||
// slot untouched makes it process this buffer again under the
|
||||
// previous packet's length and endpoint.
|
||||
sizes[i] = 0
|
||||
continue
|
||||
}
|
||||
sizes[i] = msg.N
|
||||
@@ -271,11 +288,16 @@ func (s *ICEBind) createOrUpdateMux() {
|
||||
|
||||
func (s *ICEBind) filterOutStunMessages(buffers [][]byte, n int, addr net.Addr) (bool, error) {
|
||||
for i := range buffers {
|
||||
if !stun.IsMessage(buffers[i]) {
|
||||
if n > len(buffers[i]) {
|
||||
continue
|
||||
}
|
||||
pkt := buffers[i][:n]
|
||||
|
||||
if isWireGuardMsg(pkt) || !stun.IsMessage(pkt) {
|
||||
continue
|
||||
}
|
||||
|
||||
msg, err := s.parseSTUNMessage(buffers[i][:n])
|
||||
msg, err := s.parseSTUNMessage(pkt)
|
||||
if err != nil {
|
||||
buffers[i] = []byte{}
|
||||
return true, err
|
||||
@@ -347,18 +369,34 @@ func putMessages(msgs *[]ipv6.Message, msgsPool *sync.Pool) {
|
||||
msgsPool.Put(msgs)
|
||||
}
|
||||
|
||||
func isTransportPkg(buffers [][]byte, n int) bool {
|
||||
// The first buffer should contain at least 4 bytes for type
|
||||
if len(buffers[0]) < 4 {
|
||||
return true
|
||||
// isWireGuardMsg reports whether the packet carries a WireGuard message header: a
|
||||
// little-endian uint32 message type in the range 1..4, which leaves the three bytes
|
||||
// after the type byte zero, in a packet long enough to hold any WireGuard message.
|
||||
//
|
||||
// A well formed STUN message cannot take that shape. Its length field sits in the two
|
||||
// bytes the type must leave zero, and for a message of at least wgMinMsgSize bytes that
|
||||
// field holds at least 12, so the two framings do not overlap. The test has to be this
|
||||
// tight because stun.IsMessage only looks at the magic cookie, which in a WireGuard
|
||||
// message overlaps the receiver index: a session whose index happens to equal the cookie
|
||||
// would otherwise have all of its inbound data misrouted to the STUN handler until the
|
||||
// next rekey.
|
||||
func isWireGuardMsg(pkt []byte) bool {
|
||||
if len(pkt) < wgMinMsgSize {
|
||||
return false
|
||||
}
|
||||
|
||||
// WireGuard packet type is a little-endian uint32 at start
|
||||
packetType := binary.LittleEndian.Uint32(buffers[0][:4])
|
||||
|
||||
// Check if packetType matches known WireGuard message types
|
||||
if packetType == 4 && n > 32 {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
msgType := binary.LittleEndian.Uint32(pkt[:4])
|
||||
return msgType >= wgMsgTypeHandshakeInitiation && msgType <= wgMsgTypeTransport
|
||||
}
|
||||
|
||||
// isTransportPkg reports whether the packet is WireGuard transport data carrying a
|
||||
// payload, which is what counts as peer activity. A keepalive holds no payload and is
|
||||
// exactly wgMinMsgSize bytes.
|
||||
func isTransportPkg(buffers [][]byte, n int) bool {
|
||||
if n < 4 || n > len(buffers[0]) {
|
||||
return false
|
||||
}
|
||||
|
||||
msgType := binary.LittleEndian.Uint32(buffers[0][:4])
|
||||
return msgType == wgMsgTypeTransport && n > wgMinMsgSize
|
||||
}
|
||||
|
||||
215
client/iface/bind/stun_filter_test.go
Normal file
215
client/iface/bind/stun_filter_test.go
Normal file
@@ -0,0 +1,215 @@
|
||||
//go:build !js
|
||||
|
||||
package bind
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"net"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/pion/stun/v3"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"golang.org/x/net/ipv4"
|
||||
wgConn "golang.zx2c4.com/wireguard/conn"
|
||||
)
|
||||
|
||||
// magicCookieBytes is the STUN magic cookie as it appears on the wire. In a
|
||||
// WireGuard message the same offset holds the receiver (or sender) index, which is
|
||||
// a random uint32, so a session can draw exactly this value.
|
||||
var magicCookieBytes = []byte{0x21, 0x12, 0xA4, 0x42}
|
||||
|
||||
const testBufSize = 1500
|
||||
|
||||
// wgMsg builds a WireGuard message of the given type and size, with the index field
|
||||
// at bytes 4:8 set to index.
|
||||
func wgMsg(msgType uint32, size int, index []byte) []byte {
|
||||
pkt := make([]byte, size)
|
||||
binary.LittleEndian.PutUint32(pkt[:4], msgType)
|
||||
copy(pkt[4:8], index)
|
||||
return pkt
|
||||
}
|
||||
|
||||
// intoBuffer copies pkt into a full-size receive buffer, the way the kernel read
|
||||
// does, so tests see the same buffer/length split as the hot path.
|
||||
func intoBuffer(pkt []byte) [][]byte {
|
||||
buf := make([]byte, testBufSize)
|
||||
copy(buf, pkt)
|
||||
return [][]byte{buf}
|
||||
}
|
||||
|
||||
func TestFilterOutStunMessages_PassesWireGuardWithCookieShapedIndex(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
msgType uint32
|
||||
size int
|
||||
}{
|
||||
{"transport data", wgMsgTypeTransport, 128},
|
||||
{"keepalive", wgMsgTypeTransport, wgMinMsgSize},
|
||||
{"handshake initiation", wgMsgTypeHandshakeInitiation, 148},
|
||||
{"handshake response", 2, 92},
|
||||
{"cookie reply", 3, 64},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
pkt := wgMsg(tc.msgType, tc.size, magicCookieBytes)
|
||||
require.True(t, stun.IsMessage(pkt), "precondition: pion sees this as STUN")
|
||||
|
||||
buffers := intoBuffer(pkt)
|
||||
bind := &ICEBind{}
|
||||
|
||||
filtered, err := bind.filterOutStunMessages(buffers, tc.size, &net.UDPAddr{})
|
||||
assert.NoError(t, err)
|
||||
assert.False(t, filtered, "WireGuard message must be handed to WireGuard, not the STUN handler")
|
||||
assert.Len(t, buffers[0], testBufSize, "buffer must be left intact for WireGuard")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterOutStunMessages_FiltersRealSTUNMessage(t *testing.T) {
|
||||
msg, err := stun.Build(stun.BindingRequest, stun.TransactionID, stun.Fingerprint)
|
||||
require.NoError(t, err)
|
||||
|
||||
buffers := intoBuffer(msg.Raw)
|
||||
bind := &ICEBind{}
|
||||
|
||||
filtered, err := bind.filterOutStunMessages(buffers, len(msg.Raw), &net.UDPAddr{})
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, filtered, "STUN message must be consumed by the STUN handler")
|
||||
assert.Empty(t, buffers[0], "consumed buffer must be emptied so WireGuard does not see it")
|
||||
}
|
||||
|
||||
// TestIsWireGuardMsg_DisjointFromSTUN locks the invariant the filter relies on: a
|
||||
// well formed STUN message long enough to be a WireGuard message always has a
|
||||
// non-zero length field, so it cannot be mistaken for a WireGuard header.
|
||||
func TestIsWireGuardMsg_DisjointFromSTUN(t *testing.T) {
|
||||
types := []stun.MessageType{
|
||||
stun.BindingRequest,
|
||||
stun.BindingSuccess,
|
||||
stun.BindingError,
|
||||
{Method: stun.MethodBinding, Class: stun.ClassIndication},
|
||||
}
|
||||
|
||||
for _, msgType := range types {
|
||||
// Long enough that the length guard is not what makes this pass.
|
||||
msg, err := stun.Build(msgType, stun.TransactionID,
|
||||
stun.NewUsername("remoteUfrag:localUfrag"), stun.Fingerprint)
|
||||
require.NoError(t, err)
|
||||
require.GreaterOrEqual(t, len(msg.Raw), wgMinMsgSize, "precondition: %s", msgType)
|
||||
assert.False(t, isWireGuardMsg(msg.Raw),
|
||||
"%s must not look like a WireGuard message", msgType)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsWireGuardMsg(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
pkt []byte
|
||||
want bool
|
||||
}{
|
||||
{"transport data", wgMsg(wgMsgTypeTransport, 128, nil), true},
|
||||
{"handshake initiation", wgMsg(wgMsgTypeHandshakeInitiation, 148, nil), true},
|
||||
{"unknown type 5", wgMsg(5, 128, nil), false},
|
||||
{"type 0", wgMsg(0, 128, nil), false},
|
||||
{"non-zero reserved byte", []byte{0x04, 0x00, 0x01, 0x00}, false},
|
||||
{"too short", []byte{0x04, 0x00, 0x00}, false},
|
||||
{"empty", nil, false},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
assert.Equal(t, tc.want, isWireGuardMsg(tc.pkt), "wrong classification for %s", tc.name)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestFilterOutStunMessages_IgnoresBytesBeyondPacket guards against classifying on
|
||||
// buffer contents left over from an earlier, longer packet.
|
||||
func TestFilterOutStunMessages_IgnoresBytesBeyondPacket(t *testing.T) {
|
||||
buf := make([]byte, testBufSize)
|
||||
copy(buf[4:8], magicCookieBytes)
|
||||
buffers := [][]byte{buf}
|
||||
bind := &ICEBind{}
|
||||
|
||||
filtered, err := bind.filterOutStunMessages(buffers, 2, &net.UDPAddr{})
|
||||
assert.NoError(t, err)
|
||||
assert.False(t, filtered, "a 2 byte packet must not be classified from stale buffer bytes")
|
||||
}
|
||||
|
||||
// TestReceiveFn_ClearsSizeOfConsumedPacket covers the accounting WireGuard relies
|
||||
// on: sizes is reused across reads, so a slot whose packet was consumed as STUN must
|
||||
// be reported as empty. Otherwise WireGuard reprocesses the same buffer under the
|
||||
// previous packet's length, which for a WireGuard-shaped packet means it is handled
|
||||
// twice.
|
||||
func TestReceiveFn_ClearsSizeOfConsumedPacket(t *testing.T) {
|
||||
conn := listenUDP(t, "udp4", "127.0.0.1:0")
|
||||
defer conn.Close()
|
||||
|
||||
recvFn := receiverCreator{setupICEBind(t)}.CreateReceiverFn(
|
||||
ipv4.NewPacketConn(conn), conn, false, createMsgPool(),
|
||||
)
|
||||
|
||||
msg, err := stun.Build(stun.BindingRequest, stun.TransactionID, stun.Fingerprint)
|
||||
require.NoError(t, err)
|
||||
|
||||
sender := listenUDP(t, "udp4", "127.0.0.1:0")
|
||||
defer sender.Close()
|
||||
_, err = sender.WriteTo(msg.Raw, conn.LocalAddr())
|
||||
require.NoError(t, err)
|
||||
|
||||
require.NoError(t, conn.SetReadDeadline(time.Now().Add(3*time.Second)))
|
||||
|
||||
bufs := [][]byte{make([]byte, 1500)}
|
||||
// A leftover size from an earlier read, which is what makes the missing reset
|
||||
// observable.
|
||||
sizes := []int{148}
|
||||
eps := make([]wgConn.Endpoint, 1)
|
||||
|
||||
n, err := recvFn(bufs, sizes, eps)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 1, n)
|
||||
assert.Zero(t, sizes[0], "consumed STUN packet must not leave a size behind for WireGuard")
|
||||
}
|
||||
|
||||
func TestIsTransportPkg(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
pkt []byte
|
||||
n int
|
||||
want bool
|
||||
}{
|
||||
{"transport data with payload", wgMsg(wgMsgTypeTransport, 128, nil), 128, true},
|
||||
{"keepalive", wgMsg(wgMsgTypeTransport, wgMinMsgSize, nil), wgMinMsgSize, false},
|
||||
{"handshake initiation", wgMsg(wgMsgTypeHandshakeInitiation, 148, nil), 148, false},
|
||||
{"stale type bytes beyond packet", wgMsg(wgMsgTypeTransport, 128, nil), 2, false},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
assert.Equal(t, tc.want, isTransportPkg(intoBuffer(tc.pkt), tc.n),
|
||||
"wrong activity classification for %s", tc.name)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestFilterOutStunMessages_ConsumesSTUNWithWireGuardShapedType covers the one STUN
|
||||
// encoding whose leading bytes collide with a WireGuard message type: method 0x080 as a
|
||||
// request encodes to 0x0200, so the type byte reads as a handshake response and the byte
|
||||
// after it is zero. Only the length check keeps such a message out of WireGuard's hands.
|
||||
// pion implements no method in that range, so this is a synthetic worst case rather than
|
||||
// traffic ICE produces.
|
||||
func TestFilterOutStunMessages_ConsumesSTUNWithWireGuardShapedType(t *testing.T) {
|
||||
msg, err := stun.Build(stun.NewType(stun.Method(0x080), stun.ClassRequest), stun.TransactionID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, []byte{0x02, 0x00, 0x00, 0x00}, msg.Raw[:4],
|
||||
"precondition: the leading bytes read as a WireGuard message type")
|
||||
|
||||
buffers := intoBuffer(msg.Raw)
|
||||
bind := &ICEBind{}
|
||||
|
||||
filtered, err := bind.filterOutStunMessages(buffers, len(msg.Raw), &net.UDPAddr{})
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, filtered, "STUN message must be consumed despite its WireGuard-shaped type")
|
||||
}
|
||||
@@ -22,8 +22,6 @@
|
||||
!define UI_REG_APP_PATH "Software\Microsoft\Windows\CurrentVersion\App Paths\${UI_APP_EXE}"
|
||||
!define UI_UNINSTALL_PATH "Software\Microsoft\Windows\CurrentVersion\Uninstall\${UI_APP_NAME}"
|
||||
|
||||
!define AUTOSTART_REG_KEY "Software\Microsoft\Windows\CurrentVersion\Run"
|
||||
|
||||
!define NETBIRD_DATA_DIR "$COMMONPROGRAMDATA\Netbird"
|
||||
|
||||
Unicode True
|
||||
@@ -228,13 +226,6 @@ WriteRegStr ${REG_ROOT} "${UNINSTALL_PATH}" "Publisher" "${COMP_NAME}"
|
||||
|
||||
WriteRegStr ${REG_ROOT} "${UI_REG_APP_PATH}" "" "$INSTDIR\${UI_APP_EXE}"
|
||||
|
||||
; Autostart is owned by the UI's per-user setting (HKCU\...\Run via Wails),
|
||||
; not the installer. Drop the machine-wide entry older installers wrote so the
|
||||
; toggle is the single source of truth. HKCU is left untouched -- it may hold
|
||||
; the user's own toggle state, which must survive upgrades.
|
||||
DetailPrint "Removing installer-managed autostart registry entry if present..."
|
||||
DeleteRegValue HKLM "${AUTOSTART_REG_KEY}" "${APP_NAME}"
|
||||
|
||||
EnVar::SetHKLM
|
||||
EnVar::AddValueEx "path" "$INSTDIR"
|
||||
|
||||
@@ -299,15 +290,6 @@ ExecWait '"$INSTDIR\${MAIN_APP_EXE}" service uninstall'
|
||||
DetailPrint "Terminating Netbird UI process..."
|
||||
ExecWait `taskkill /im ${UI_APP_EXE}.exe /f`
|
||||
|
||||
; Remove autostart registry entries
|
||||
DetailPrint "Removing autostart registry entries if they exist..."
|
||||
; Legacy machine-wide entry written by older installers.
|
||||
DeleteRegValue HKLM "${AUTOSTART_REG_KEY}" "${APP_NAME}"
|
||||
; Per-user entry the UI toggle writes via Wails (value name is the lowercase
|
||||
; app-name slug). Uninstall removes the app, so drop it too.
|
||||
DeleteRegValue HKCU "${AUTOSTART_REG_KEY}" "${APP_NAME}"
|
||||
DeleteRegValue HKCU "${AUTOSTART_REG_KEY}" "netbird"
|
||||
|
||||
; Handle data deletion based on checkbox
|
||||
DetailPrint "Checking if user requested data deletion..."
|
||||
${If} $DeleteDataEnabled == "1"
|
||||
|
||||
@@ -138,26 +138,37 @@ func (a *Auth) IsSSOSupported(ctx context.Context) (bool, error) {
|
||||
|
||||
// GetOAuthFlow returns an OAuth flow (PKCE or Device) using the existing management connection
|
||||
// This avoids creating a new connection to the management server
|
||||
func (a *Auth) GetOAuthFlow(ctx context.Context, forceDeviceAuth bool) (OAuthFlow, error) {
|
||||
func (a *Auth) GetOAuthFlow(ctx context.Context, forceDeviceAuth bool, hint string) (OAuthFlow, error) {
|
||||
var flow OAuthFlow
|
||||
var err error
|
||||
|
||||
err = a.withRetry(ctx, func(client *mgm.GrpcClient) error {
|
||||
err := a.withRetry(ctx, func(client *mgm.GrpcClient) error {
|
||||
if forceDeviceAuth {
|
||||
flow, err = a.getDeviceFlow(client)
|
||||
return err
|
||||
deviceFlow, err := a.getDeviceFlow(client)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
deviceFlow.SetLoginHint(hint)
|
||||
flow = deviceFlow
|
||||
return nil
|
||||
}
|
||||
|
||||
// Try PKCE flow first
|
||||
flow, err = a.getPKCEFlow(client)
|
||||
pkceFlow, err := a.getPKCEFlow(client)
|
||||
if err != nil {
|
||||
// If PKCE not supported, try Device flow
|
||||
if s, ok := status.FromError(err); ok && (s.Code() == codes.NotFound || s.Code() == codes.Unimplemented) {
|
||||
flow, err = a.getDeviceFlow(client)
|
||||
return err
|
||||
deviceFlow, err := a.getDeviceFlow(client)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
deviceFlow.SetLoginHint(hint)
|
||||
flow = deviceFlow
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
pkceFlow.SetLoginHint(hint)
|
||||
flow = pkceFlow
|
||||
return nil
|
||||
})
|
||||
|
||||
|
||||
@@ -97,9 +97,7 @@ func authenticateWithPKCEFlow(ctx context.Context, config *profilemanager.Config
|
||||
return nil, fmt.Errorf("getting pkce authorization flow info failed with error: %v", err)
|
||||
}
|
||||
|
||||
if hint != "" {
|
||||
pkceFlowInfo.SetLoginHint(hint)
|
||||
}
|
||||
pkceFlowInfo.SetLoginHint(hint)
|
||||
|
||||
return pkceFlowInfo, nil
|
||||
}
|
||||
@@ -127,9 +125,7 @@ func authenticateWithDeviceCodeFlow(ctx context.Context, config *profilemanager.
|
||||
}
|
||||
}
|
||||
|
||||
if hint != "" {
|
||||
deviceFlowInfo.SetLoginHint(hint)
|
||||
}
|
||||
deviceFlowInfo.SetLoginHint(hint)
|
||||
|
||||
return deviceFlowInfo, nil
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ import (
|
||||
"github.com/netbirdio/netbird/client/iface/device"
|
||||
"github.com/netbirdio/netbird/client/iface/netstack"
|
||||
"github.com/netbirdio/netbird/client/internal/dns"
|
||||
"github.com/netbirdio/netbird/client/internal/filedrop"
|
||||
"github.com/netbirdio/netbird/client/internal/lazyconn"
|
||||
"github.com/netbirdio/netbird/client/internal/listener"
|
||||
"github.com/netbirdio/netbird/client/internal/metrics"
|
||||
@@ -38,6 +39,8 @@ import (
|
||||
"github.com/netbirdio/netbird/client/internal/updater"
|
||||
"github.com/netbirdio/netbird/client/internal/updater/installer"
|
||||
nbnet "github.com/netbirdio/netbird/client/net"
|
||||
"github.com/netbirdio/netbird/client/netstate"
|
||||
"github.com/netbirdio/netbird/client/netsweep"
|
||||
cProto "github.com/netbirdio/netbird/client/proto"
|
||||
"github.com/netbirdio/netbird/client/ssh"
|
||||
sshconfig "github.com/netbirdio/netbird/client/ssh/config"
|
||||
@@ -64,24 +67,49 @@ type ConnectClient struct {
|
||||
config *profilemanager.Config
|
||||
statusRecorder *peer.Status
|
||||
|
||||
engine *Engine
|
||||
engineMutex sync.Mutex
|
||||
clientMetrics *metrics.ClientMetrics
|
||||
updateManager *updater.Manager
|
||||
engine *Engine
|
||||
engineMutex sync.Mutex
|
||||
clientMetrics *metrics.ClientMetrics
|
||||
updateManager *updater.Manager
|
||||
fileDropManager *filedrop.Manager
|
||||
|
||||
persistSyncResponse bool
|
||||
|
||||
// netState gates every reconnection loop on OS-reported network
|
||||
// availability. Nil (the default) disables gating; mobile platforms
|
||||
// inject it via WithNetworkState.
|
||||
netState *netstate.State
|
||||
|
||||
// sweeper cuts the management, signal and relay connections on network
|
||||
// change; nil disables it.
|
||||
sweeper *netsweep.Sweeper
|
||||
}
|
||||
|
||||
// ConnectClientOption configures optional ConnectClient behavior.
|
||||
type ConnectClientOption func(*ConnectClient)
|
||||
|
||||
// WithNetworkState injects the OS network availability state that gates every
|
||||
// reconnection loop; without it gating is disabled.
|
||||
func WithNetworkState(netState *netstate.State) ConnectClientOption {
|
||||
return func(c *ConnectClient) { c.netState = netState }
|
||||
}
|
||||
|
||||
// WithSweeper injects the network change sweeper.
|
||||
func WithSweeper(sweeper *netsweep.Sweeper) ConnectClientOption {
|
||||
return func(c *ConnectClient) { c.sweeper = sweeper }
|
||||
}
|
||||
|
||||
func NewConnectClient(
|
||||
ctx context.Context,
|
||||
config *profilemanager.Config,
|
||||
statusRecorder *peer.Status,
|
||||
opts ...ConnectClientOption,
|
||||
) *ConnectClient {
|
||||
// Derive the run context here so Stop owns the cancel that unblocks the run
|
||||
// loop. runCancel is set once at construction, so Stop can call it without
|
||||
// racing the run loop's startup. Callers therefore need not cancel before Stop.
|
||||
runCtx, runCancel := context.WithCancel(ctx)
|
||||
return &ConnectClient{
|
||||
c := &ConnectClient{
|
||||
ctx: runCtx,
|
||||
runCancel: runCancel,
|
||||
runExited: make(chan struct{}),
|
||||
@@ -89,12 +117,22 @@ func NewConnectClient(
|
||||
statusRecorder: statusRecorder,
|
||||
engineMutex: sync.Mutex{},
|
||||
}
|
||||
for _, opt := range opts {
|
||||
opt(c)
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
func (c *ConnectClient) SetUpdateManager(um *updater.Manager) {
|
||||
c.updateManager = um
|
||||
}
|
||||
|
||||
// SetFileDropManager hands the engine the active profile's file drop manager, so
|
||||
// the transfer receiver starts and stops with the tunnel. Must be set before Run.
|
||||
func (c *ConnectClient) SetFileDropManager(m *filedrop.Manager) {
|
||||
c.fileDropManager = m
|
||||
}
|
||||
|
||||
// Run with main logic.
|
||||
func (c *ConnectClient) Run(runningChan chan struct{}, logPath string) error {
|
||||
if androidRunOverride != nil {
|
||||
@@ -113,11 +151,14 @@ func (c *ConnectClient) RunOnAndroid(
|
||||
stateFilePath string,
|
||||
cacheDir string,
|
||||
) error {
|
||||
notifier := tunnelnotifier.New(networkChangeListener, nil)
|
||||
defer notifier.Close()
|
||||
|
||||
// in case of non Android os these variables will be nil
|
||||
mobileDependency := MobileDependency{
|
||||
TunAdapter: tunAdapter,
|
||||
IFaceDiscover: iFaceDiscover,
|
||||
NetworkChangeListener: networkChangeListener,
|
||||
NetworkChangeListener: notifier,
|
||||
HostDNSAddresses: dnsAddresses,
|
||||
DnsReadyListener: dnsReadyListener,
|
||||
StateFilePath: stateFilePath,
|
||||
@@ -271,6 +312,13 @@ func (c *ConnectClient) run(mobileDependency MobileDependency, runningChan chan
|
||||
return nil
|
||||
}
|
||||
|
||||
// suspend connection attempts while the OS reports no usable network
|
||||
if waited, err := c.netState.Wait(c.ctx); err != nil {
|
||||
return nil
|
||||
} else if waited {
|
||||
backOff.Reset()
|
||||
}
|
||||
|
||||
state.Set(StatusConnecting)
|
||||
|
||||
engineCtx, cancel := context.WithCancel(c.ctx)
|
||||
@@ -282,7 +330,8 @@ func (c *ConnectClient) run(mobileDependency MobileDependency, runningChan chan
|
||||
}()
|
||||
|
||||
log.Debugf("connecting to the Management service %s", c.config.ManagementURL.Host)
|
||||
mgmClient, err := mgm.NewClient(engineCtx, c.config.ManagementURL.Host, myPrivateKey, mgmTlsEnabled)
|
||||
mgmClient, err := mgm.NewClient(engineCtx, c.config.ManagementURL.Host, myPrivateKey, mgmTlsEnabled,
|
||||
mgm.WithNetworkState(c.netState), mgm.WithSweeper(c.sweeper))
|
||||
if err != nil {
|
||||
// On daemon shutdown / Down() the parent context is cancelled
|
||||
// and the dial fails with "context canceled". Wrapping that
|
||||
@@ -357,7 +406,7 @@ func (c *ConnectClient) run(mobileDependency MobileDependency, runningChan chan
|
||||
}()
|
||||
|
||||
// with the global Netbird config in hand connect (just a connection, no stream yet) Signal
|
||||
signalClient, err := connectToSignal(engineCtx, loginResp.GetNetbirdConfig(), myPrivateKey)
|
||||
signalClient, err := connectToSignal(engineCtx, loginResp.GetNetbirdConfig(), myPrivateKey, c.netState, c.sweeper)
|
||||
if err != nil {
|
||||
log.Error(err)
|
||||
return wrapErr(err)
|
||||
@@ -393,7 +442,8 @@ func (c *ConnectClient) run(mobileDependency MobileDependency, runningChan chan
|
||||
engineConfig.StateDir = filepath.Dir(path)
|
||||
}
|
||||
|
||||
relayManager := relayClient.NewManager(engineCtx, relayURLs, myPrivateKey.PublicKey().String(), engineConfig.MTU)
|
||||
relayManager := relayClient.NewManager(engineCtx, relayURLs, myPrivateKey.PublicKey().String(), engineConfig.MTU,
|
||||
relayClient.WithNetworkState(c.netState), relayClient.WithSweeper(c.sweeper))
|
||||
c.statusRecorder.SetRelayMgr(relayManager)
|
||||
if len(relayURLs) > 0 {
|
||||
if token != nil {
|
||||
@@ -421,6 +471,8 @@ func (c *ConnectClient) run(mobileDependency MobileDependency, runningChan chan
|
||||
UpdateManager: c.updateManager,
|
||||
ClientMetrics: c.clientMetrics,
|
||||
MetricsCtx: c.ctx,
|
||||
FileDrop: c.fileDropManager,
|
||||
NetState: c.netState,
|
||||
}, mobileDependency)
|
||||
engine.SetSyncResponsePersistence(c.persistSyncResponse)
|
||||
c.engine = engine
|
||||
@@ -477,6 +529,16 @@ func (c *ConnectClient) run(mobileDependency MobileDependency, runningChan chan
|
||||
// status stream stuck at Connecting.
|
||||
err = backoff.Retry(operation, backoff.WithContext(backOff, c.ctx))
|
||||
if err != nil {
|
||||
// Once the client context is cancelled backoff.WithContext surfaces the
|
||||
// bare context error, and any attempt torn down mid-flight reports the
|
||||
// same. That cancellation is the caller asking us to stop (Stop, Down or
|
||||
// an engine restart), so exit cleanly instead of handing back a failure
|
||||
// the caller would have to distinguish from a real one.
|
||||
if c.ctx.Err() != nil && errors.Is(err, context.Canceled) {
|
||||
log.Info("exiting client retry loop, context cancelled")
|
||||
return nil
|
||||
}
|
||||
|
||||
log.Debugf("exiting client retry loop due to unrecoverable error: %s", err)
|
||||
if s, ok := gstatus.FromError(err); ok && (s.Code() == codes.PermissionDenied) {
|
||||
state.Set(StatusNeedsLogin)
|
||||
@@ -670,7 +732,7 @@ func selectMTU(localMTU uint16, peerMTU int32) uint16 {
|
||||
}
|
||||
|
||||
// connectToSignal creates Signal Service client and established a connection
|
||||
func connectToSignal(ctx context.Context, wtConfig *mgmProto.NetbirdConfig, ourPrivateKey wgtypes.Key) (*signal.GrpcClient, error) {
|
||||
func connectToSignal(ctx context.Context, wtConfig *mgmProto.NetbirdConfig, ourPrivateKey wgtypes.Key, netState *netstate.State, sweeper *netsweep.Sweeper) (*signal.GrpcClient, error) {
|
||||
var sigTLSEnabled bool
|
||||
if wtConfig.Signal.Protocol == mgmProto.HostConfig_HTTPS {
|
||||
sigTLSEnabled = true
|
||||
@@ -678,7 +740,8 @@ func connectToSignal(ctx context.Context, wtConfig *mgmProto.NetbirdConfig, ourP
|
||||
sigTLSEnabled = false
|
||||
}
|
||||
|
||||
signalClient, err := signal.NewClient(ctx, wtConfig.Signal.Uri, ourPrivateKey, sigTLSEnabled)
|
||||
signalClient, err := signal.NewClient(ctx, wtConfig.Signal.Uri, ourPrivateKey, sigTLSEnabled,
|
||||
signal.WithNetworkState(netState), signal.WithSweeper(sweeper))
|
||||
if err != nil {
|
||||
log.Errorf("error while connecting to the Signal Exchange Service %s: %s", wtConfig.Signal.Uri, err)
|
||||
return nil, gstatus.Errorf(codes.FailedPrecondition, "failed connecting to Signal Service : %s", err)
|
||||
|
||||
@@ -34,9 +34,8 @@ import (
|
||||
"github.com/netbirdio/netbird/shared/netiputil"
|
||||
)
|
||||
|
||||
const readmeContent = `Netbird debug bundle
|
||||
This debug bundle contains the following files.
|
||||
If the --anonymize flag is set, the files are anonymized to protect sensitive information.
|
||||
const readmeContent = `This debug bundle contains the following files.
|
||||
If anonymization is enabled (--anonymize / --anonymize-level), the files are anonymized to protect sensitive information.
|
||||
|
||||
status.txt: Anonymized status information of the NetBird client.
|
||||
client.log: Most recent, anonymized client log file of the NetBird client.
|
||||
@@ -52,6 +51,7 @@ nftables.txt: Anonymized nftables rules with packet counters across all families
|
||||
sysctls.txt: Forwarding, reverse-path filter, source-validation, and conntrack accounting sysctl values that the NetBird client may read or modify, if --system-info flag was provided (Linux only).
|
||||
resolv.conf: DNS resolver configuration from /etc/resolv.conf (Unix systems only), if --system-info flag was provided.
|
||||
scutil_dns.txt: DNS configuration from scutil --dns (macOS only), if --system-info flag was provided.
|
||||
dns_windows.txt: Anonymized NRPT rules and policy table in effect, DNS client policy, and per-interface and per-adapter DNS configuration (Windows only), if --system-info flag was provided.
|
||||
resolved_domains.txt: Anonymized resolved domain IP addresses from the status recorder.
|
||||
config.txt: Anonymized configuration information of the NetBird client.
|
||||
network_map.json: Anonymized sync response containing peer configurations, routes, DNS settings, and firewall rules.
|
||||
@@ -70,21 +70,34 @@ capture.pcap: Packet capture in pcap format. Only present when capture was runni
|
||||
|
||||
|
||||
Anonymization Process
|
||||
The files in this bundle have been anonymized to protect sensitive information. Here's how the anonymization was applied:
|
||||
The files in this bundle have been anonymized to protect sensitive information. The level applied to this bundle is recorded at the top of this file. Here's how the anonymization was applied:
|
||||
|
||||
IP Addresses
|
||||
|
||||
IPv4 addresses are replaced with addresses starting from 198.51.100.0
|
||||
IPv6 addresses are replaced with addresses starting from 100::
|
||||
Default level:
|
||||
- Public IPv4 addresses are replaced with addresses starting from 198.51.100.0
|
||||
- Public IPv6 addresses are replaced with addresses starting from 2001:db8:ffff::
|
||||
- IPv6 unique local addresses (fc00::/7) are anonymized as well: their random global ID uniquely identifies the network.
|
||||
- IP addresses from internal IPv4 ranges and well-known addresses are not anonymized (e.g. 8.8.8.8, 100.64.0.0/10, addresses starting with 192.168., 172.16., 10., 169.254., fe80::).
|
||||
|
||||
Strict level (--anonymize-level strict), in addition to the default level:
|
||||
- Private (RFC 1918), CGNAT (100.64.0.0/10), and link-local (169.254.0.0/16, fe80::/10) addresses are anonymized too.
|
||||
- Internal IPv4 addresses are replaced with addresses starting from 198.18.0.0 and internal IPv6 addresses with addresses starting from 2001:db8:1::, so internal addresses remain distinguishable from public ones.
|
||||
- Addresses are mapped in order of first appearance: subnet structure, allocation scheme, and gateway conventions are not preserved. Prefix lengths of networks are preserved.
|
||||
- Peer names in front of NetBird domains are replaced with numbered placeholders (e.g. peer-1.netbird.cloud), and subdomain labels of other domains with host-N placeholders.
|
||||
- WireGuard public keys are replaced with consistent placeholder keys.
|
||||
|
||||
IP addresses from non public ranges and well known addresses are not anonymized (e.g. 8.8.8.8, 100.64.0.0/10, addresses starting with 192.168., 172.16., 10., etc.).
|
||||
Reoccuring IP addresses are replaced with the same anonymized address.
|
||||
|
||||
Note: The anonymized IP addresses in the status file do not match those in the log and routes files. However, the anonymized IP addresses are consistent within the status file and across the routes and log files.
|
||||
|
||||
MAC Addresses
|
||||
MAC addresses are replaced at every anonymization level with consistent placeholders counting up from 02:00:00:00:00:01. Broadcast, multicast, and all-zero addresses are kept. At the default level a preserved IPv6 link-local address may still embed a MAC address (EUI-64); the strict level anonymizes those addresses.
|
||||
|
||||
Domains
|
||||
All domain names (except for the netbird domains) are replaced with randomly generated strings ending in ".domain". Anonymized domains are consistent across all files in the bundle.
|
||||
Reoccuring domain names are replaced with the same anonymized domain.
|
||||
At the strict level, the peer name labels in front of netbird domains are anonymized as well.
|
||||
|
||||
Sync Response
|
||||
The network_map.json file contains the following anonymized information:
|
||||
@@ -225,6 +238,13 @@ scutil_dns.txt (macOS only):
|
||||
- Shows DNS configuration for all network interfaces
|
||||
- Includes search domains, nameservers, and DNS resolver settings
|
||||
- All IP addresses and domain names are anonymized
|
||||
|
||||
dns_windows.txt (Windows only):
|
||||
- Lists the NRPT rules of both policy stores, the local one and the group policy one, marking the rules the client created
|
||||
- Follows them with the policy table the resolver has loaded, which differs from the rules while a change has not been picked up yet
|
||||
- Includes the DNS client group policy, the global TCP/IP and Dnscache parameters, and the DNS values of every interface that has any
|
||||
- Ends with the resolver configuration in effect per adapter, from GetAdaptersAddresses
|
||||
- All IP addresses and domain names are anonymized
|
||||
`
|
||||
|
||||
const (
|
||||
@@ -281,6 +301,7 @@ type BundleGenerator struct {
|
||||
cliVersion string
|
||||
|
||||
anonymize bool
|
||||
anonymizeLevel anonymize.Level
|
||||
includeSystemInfo bool
|
||||
logFileCount uint32
|
||||
|
||||
@@ -288,7 +309,10 @@ type BundleGenerator struct {
|
||||
}
|
||||
|
||||
type BundleConfig struct {
|
||||
Anonymize bool
|
||||
Anonymize bool
|
||||
// AnonymizeLevel selects how much the anonymizer redacts.
|
||||
// anonymize.LevelStrict implies Anonymize.
|
||||
AnonymizeLevel anonymize.Level
|
||||
IncludeSystemInfo bool
|
||||
LogFileCount uint32
|
||||
}
|
||||
@@ -327,8 +351,11 @@ func NewBundleGenerator(deps GeneratorDependencies, cfg BundleConfig) *BundleGen
|
||||
uiLogOpener = openLogFile
|
||||
}
|
||||
|
||||
anonymizer := anonymize.NewAnonymizer(anonymize.DefaultAddresses())
|
||||
anonymizer.SetLevel(cfg.AnonymizeLevel)
|
||||
|
||||
return &BundleGenerator{
|
||||
anonymizer: anonymize.NewAnonymizer(anonymize.DefaultAddresses()),
|
||||
anonymizer: anonymizer,
|
||||
|
||||
internalConfig: deps.InternalConfig,
|
||||
statusRecorder: deps.StatusRecorder,
|
||||
@@ -345,7 +372,8 @@ func NewBundleGenerator(deps GeneratorDependencies, cfg BundleConfig) *BundleGen
|
||||
daemonVersion: deps.DaemonVersion,
|
||||
cliVersion: deps.CliVersion,
|
||||
|
||||
anonymize: cfg.Anonymize,
|
||||
anonymize: cfg.Anonymize || cfg.AnonymizeLevel >= anonymize.LevelStrict,
|
||||
anonymizeLevel: cfg.AnonymizeLevel,
|
||||
includeSystemInfo: cfg.IncludeSystemInfo,
|
||||
logFileCount: logFileCount,
|
||||
}
|
||||
@@ -485,7 +513,13 @@ func (g *BundleGenerator) addSystemInfo() {
|
||||
}
|
||||
|
||||
func (g *BundleGenerator) addReadme() error {
|
||||
readmeReader := strings.NewReader(readmeContent)
|
||||
level := "none (anonymization disabled)"
|
||||
if g.anonymize {
|
||||
level = g.anonymizeLevel.String()
|
||||
}
|
||||
header := fmt.Sprintf("Netbird debug bundle\nAnonymization level applied to this bundle: %s\n", level)
|
||||
|
||||
readmeReader := strings.NewReader(header + readmeContent)
|
||||
if err := g.addFileToZip(readmeReader, "README.txt"); err != nil {
|
||||
return fmt.Errorf("add README file to zip: %w", err)
|
||||
}
|
||||
@@ -507,9 +541,10 @@ func (g *BundleGenerator) addStatus() error {
|
||||
fullStatus := g.statusRecorder.GetFullStatus()
|
||||
protoFullStatus := nbstatus.ToProtoFullStatus(fullStatus)
|
||||
overview := nbstatus.ConvertToStatusOutputOverview(protoFullStatus, nbstatus.ConvertOptions{
|
||||
Anonymize: g.anonymize,
|
||||
ProfileName: profName,
|
||||
DaemonVersion: g.daemonVersion,
|
||||
Anonymize: g.anonymize,
|
||||
AnonymizeLevel: g.anonymizeLevel,
|
||||
ProfileName: profName,
|
||||
DaemonVersion: g.daemonVersion,
|
||||
})
|
||||
overview.CliVersion = g.cliVersion
|
||||
statusOutput := overview.FullDetailSummary()
|
||||
@@ -662,7 +697,7 @@ func (g *BundleGenerator) addCommonConfigFields(configContent *strings.Builder)
|
||||
configContent.WriteString("NetBird Client Configuration:\n\n")
|
||||
|
||||
if key, err := wgtypes.ParseKey(g.internalConfig.PrivateKey); err == nil {
|
||||
configContent.WriteString(fmt.Sprintf("PublicKey: %s\n", key.PublicKey().String()))
|
||||
configContent.WriteString(fmt.Sprintf("PublicKey: %s\n", g.anonymizer.AnonymizeWGKey(key.PublicKey().String())))
|
||||
}
|
||||
configContent.WriteString(fmt.Sprintf("WgIface: %s\n", g.internalConfig.WgIface))
|
||||
configContent.WriteString(fmt.Sprintf("WgPort: %d\n", g.internalConfig.WgPort))
|
||||
@@ -952,6 +987,11 @@ func (g *BundleGenerator) addUpdateLogs() error {
|
||||
}
|
||||
|
||||
baseName := filepath.Base(logFile)
|
||||
data, err = g.anonymizeBytes(data)
|
||||
if err != nil {
|
||||
log.Warnf("skipping update log file %s: %v", baseName, err)
|
||||
continue
|
||||
}
|
||||
if err := g.addFileToZip(bytes.NewReader(data), filepath.Join("update-logs", baseName)); err != nil {
|
||||
return fmt.Errorf("add update log file %s to zip: %w", baseName, err)
|
||||
}
|
||||
@@ -979,6 +1019,13 @@ func (g *BundleGenerator) addCorruptedStateFiles() error {
|
||||
}
|
||||
|
||||
fileName := filepath.Base(match)
|
||||
// Corrupted state files usually fail structured JSON anonymization,
|
||||
// so run them through the string anonymizer instead.
|
||||
data, err = g.anonymizeBytes(data)
|
||||
if err != nil {
|
||||
log.Warnf("skipping corrupted state file %s: %v", fileName, err)
|
||||
continue
|
||||
}
|
||||
if err := g.addFileToZip(bytes.NewReader(data), "corrupted_states/"+fileName); err != nil {
|
||||
log.Warnf("Failed to add corrupted state file %s to zip: %v", fileName, err)
|
||||
continue
|
||||
@@ -990,6 +1037,27 @@ func (g *BundleGenerator) addCorruptedStateFiles() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// anonymizeBytes runs raw file content through the string anonymizer line by
|
||||
// line when anonymization is enabled. It errors instead of returning partial
|
||||
// content, so a caller never adds an unanonymized fallback to the bundle.
|
||||
func (g *BundleGenerator) anonymizeBytes(data []byte) ([]byte, error) {
|
||||
if !g.anonymize {
|
||||
return data, nil
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
scanner := bufio.NewScanner(bytes.NewReader(data))
|
||||
scanner.Buffer(make([]byte, 1024*1024), 1024*1024)
|
||||
for scanner.Scan() {
|
||||
buf.WriteString(g.anonymizer.AnonymizeString(scanner.Text()))
|
||||
buf.WriteByte('\n')
|
||||
}
|
||||
if err := scanner.Err(); err != nil {
|
||||
return nil, fmt.Errorf("anonymize content: %w", err)
|
||||
}
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
func (g *BundleGenerator) addMetrics() error {
|
||||
if g.clientMetrics == nil {
|
||||
log.Debugf("skipping metrics in debug bundle: no metrics collector")
|
||||
@@ -1462,6 +1530,7 @@ func anonymizeRemotePeer(peer *mgmProto.RemotePeerConfig, anonymizer *anonymize.
|
||||
}
|
||||
|
||||
peer.Fqdn = anonymizer.AnonymizeDomain(peer.Fqdn)
|
||||
peer.WgPubKey = anonymizer.AnonymizeWGKey(peer.WgPubKey)
|
||||
|
||||
anonymizeSSHConfig(peer.SshConfig)
|
||||
}
|
||||
|
||||
@@ -844,6 +844,10 @@ func collectSysctls() string {
|
||||
[]string{"net.ipv4.conf.all.src_valid_mark", "net.ipv4.conf.default.src_valid_mark"},
|
||||
listInterfaceSysctls("ipv4", "src_valid_mark")...,
|
||||
))
|
||||
writeSysctlGroup(&builder, "accept_ra", append(
|
||||
[]string{"net.ipv6.conf.all.accept_ra", "net.ipv6.conf.default.accept_ra"},
|
||||
listInterfaceSysctls("ipv6", "accept_ra")...,
|
||||
))
|
||||
writeSysctlGroup(&builder, "conntrack", []string{
|
||||
"net.netfilter.nf_conntrack_acct",
|
||||
"net.netfilter.nf_conntrack_tcp_loose",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//go:build !unix
|
||||
//go:build !unix && !windows
|
||||
|
||||
package debug
|
||||
|
||||
|
||||
443
client/internal/debug/debug_windows.go
Normal file
443
client/internal/debug/debug_windows.go
Normal file
@@ -0,0 +1,443 @@
|
||||
//go:build windows
|
||||
|
||||
package debug
|
||||
|
||||
import (
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"strings"
|
||||
"unsafe"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
"golang.org/x/sys/windows"
|
||||
"golang.org/x/sys/windows/registry"
|
||||
|
||||
nbdns "github.com/netbirdio/netbird/client/internal/dns"
|
||||
)
|
||||
|
||||
const dnsInfoFileName = "dns_windows.txt"
|
||||
|
||||
const (
|
||||
gpoDNSClientRoot = `SOFTWARE\Policies\Microsoft\Windows NT\DNSClient`
|
||||
tcpipParamsPath = `SYSTEM\CurrentControlSet\Services\Tcpip\Parameters`
|
||||
dnscacheParams = `SYSTEM\CurrentControlSet\Services\Dnscache\Parameters`
|
||||
)
|
||||
|
||||
// interfaceDNSValues are the per-interface values that decide how a name is
|
||||
// resolved and registered. Everything the DNS host manager writes is in here,
|
||||
// so a bundle shows both what we set and what it replaced.
|
||||
var interfaceDNSValues = []string{
|
||||
"NameServer",
|
||||
"DhcpNameServer",
|
||||
"Domain",
|
||||
"DhcpDomain",
|
||||
"SearchList",
|
||||
"RegistrationEnabled",
|
||||
"DisableDynamicUpdate",
|
||||
"MaxNumberOfAddressesToRegister",
|
||||
"EnableDHCP",
|
||||
}
|
||||
|
||||
// addDNSInfo collects and adds DNS configuration information to the archive
|
||||
func (g *BundleGenerator) addDNSInfo() error {
|
||||
if err := g.addFileToZip(strings.NewReader(g.collectDNSInfo()), dnsInfoFileName); err != nil {
|
||||
return fmt.Errorf("add DNS info to zip: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// collectDNSInfo renders the report. Everything below it reaches the platform
|
||||
// through COM and through lazily resolved procedures, which panic when a
|
||||
// procedure is missing rather than returning an error, and a debug bundle is not
|
||||
// allowed to take the daemon down. The panic is contained here, and whatever was
|
||||
// collected before it is kept and reported with it.
|
||||
func (g *BundleGenerator) collectDNSInfo() (content string) {
|
||||
var sb strings.Builder
|
||||
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
log.Errorf("collecting Windows DNS configuration panicked: %v", r)
|
||||
fmt.Fprintf(&sb, "\nerror: collection stopped: %v\n", r)
|
||||
}
|
||||
content = sb.String()
|
||||
}()
|
||||
|
||||
sb.WriteString("Windows DNS configuration\n")
|
||||
sb.WriteString("=========================\n")
|
||||
|
||||
adapters, adaptersErr := adapterAddresses()
|
||||
|
||||
g.writeNRPTRules(&sb, "NRPT rules, local policy store", nbdns.DNSPolicyConfigRoot)
|
||||
g.writeNRPTRules(&sb, "NRPT rules, group policy store", nbdns.GPODNSPolicyConfigRoot)
|
||||
g.writeEffectiveNRPTPolicies(&sb)
|
||||
g.writeRegistryKey(&sb, "DNS client group policy", gpoDNSClientRoot)
|
||||
g.writeRegistryKey(&sb, "Global TCP/IP parameters", tcpipParamsPath)
|
||||
g.writeRegistryKey(&sb, "Dnscache parameters", dnscacheParams)
|
||||
g.writeInterfaceDNS(&sb, "Per-interface DNS, IPv4", nbdns.InterfaceConfigPath, adapterNames(adapters))
|
||||
g.writeInterfaceDNS(&sb, "Per-interface DNS, IPv6", nbdns.InterfaceConfigPathV6, adapterNames(adapters))
|
||||
g.writeAdapterDNS(&sb, adapters, adaptersErr)
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// writeNRPTRules lists every rule in a policy store, ours and any other
|
||||
// product's, since a foreign rule for the same namespace decides resolution
|
||||
// just as ours does. Rules the client wrote are marked.
|
||||
func (g *BundleGenerator) writeNRPTRules(sb *strings.Builder, title, root string) {
|
||||
writeSection(sb, title, root)
|
||||
|
||||
names, err := subKeyNames(root)
|
||||
if err != nil {
|
||||
fmt.Fprintf(sb, "error: %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
if len(names) == 0 {
|
||||
sb.WriteString("no rules\n")
|
||||
return
|
||||
}
|
||||
|
||||
for _, name := range names {
|
||||
owner := ""
|
||||
if strings.HasPrefix(strings.ToLower(name), strings.ToLower(nbdns.NRPTKeyPrefix)) {
|
||||
owner = " (netbird)"
|
||||
}
|
||||
fmt.Fprintf(sb, "%s%s\n", name, owner)
|
||||
g.writeValues(sb, root+`\`+name, nil, " ")
|
||||
}
|
||||
}
|
||||
|
||||
// writeEffectiveNRPTPolicies reports the table the resolver answers from, which
|
||||
// the registry cannot show: a rule is written before it is loaded, and it keeps
|
||||
// being enforced after its key is gone until the resolver reloads its policy.
|
||||
func (g *BundleGenerator) writeEffectiveNRPTPolicies(sb *strings.Builder) {
|
||||
writeSection(sb, "NRPT policy table in effect", nrptPolicyClass+"."+nrptPolicyMethod+" in "+nrptPolicyNamespace)
|
||||
|
||||
entries, err := effectiveNRPTPolicies()
|
||||
if err != nil {
|
||||
fmt.Fprintf(sb, "error: %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
if len(entries) == 0 {
|
||||
sb.WriteString("no policies\n")
|
||||
return
|
||||
}
|
||||
|
||||
for _, entry := range entries {
|
||||
fmt.Fprintf(sb, "%s\n", g.anonymizeValue("Namespace", entry.namespace))
|
||||
for _, value := range entry.values {
|
||||
fmt.Fprintf(sb, " %s: %s\n", value.name, g.anonymizeValue(value.name, value.value))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// writeInterfaceDNS reports the DNS values of every interface that has any, so
|
||||
// the netbird interface can be compared against the physical ones. The registry
|
||||
// keys the values by GUID, so each is named from the adapter list; a GUID with
|
||||
// no adapter is a leftover key of an interface that no longer exists.
|
||||
func (g *BundleGenerator) writeInterfaceDNS(sb *strings.Builder, title, root string, names map[string]string) {
|
||||
writeSection(sb, title, root)
|
||||
|
||||
guids, err := subKeyNames(root)
|
||||
if err != nil {
|
||||
fmt.Fprintf(sb, "error: %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
var reported int
|
||||
for _, guid := range guids {
|
||||
var iface strings.Builder
|
||||
g.writeValues(&iface, root+`\`+guid, interfaceDNSValues, " ")
|
||||
if iface.Len() == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
name, ok := names[strings.ToLower(guid)]
|
||||
if !ok {
|
||||
name = "no adapter with this GUID"
|
||||
}
|
||||
|
||||
reported++
|
||||
fmt.Fprintf(sb, "%s (%s)\n%s", guid, name, iface.String())
|
||||
}
|
||||
|
||||
if reported == 0 {
|
||||
sb.WriteString("no interface holds DNS values\n")
|
||||
}
|
||||
}
|
||||
|
||||
// writeRegistryKey reports the values of a single key, without its subkeys.
|
||||
func (g *BundleGenerator) writeRegistryKey(sb *strings.Builder, title, path string) {
|
||||
writeSection(sb, title, path)
|
||||
|
||||
var values strings.Builder
|
||||
g.writeValues(&values, path, nil, "")
|
||||
if values.Len() == 0 {
|
||||
sb.WriteString("no values\n")
|
||||
return
|
||||
}
|
||||
|
||||
sb.WriteString(values.String())
|
||||
}
|
||||
|
||||
// writeValues renders the values of a key. A nil names list reports every
|
||||
// value, otherwise only those named and present.
|
||||
func (g *BundleGenerator) writeValues(sb *strings.Builder, path string, names []string, indent string) {
|
||||
k, err := registry.OpenKey(registry.LOCAL_MACHINE, path, registry.QUERY_VALUE)
|
||||
switch {
|
||||
case errors.Is(err, registry.ErrNotExist), errors.Is(err, windows.ERROR_PATH_NOT_FOUND):
|
||||
// an absent key is the normal state for the GPO store and for
|
||||
// interfaces without DNS settings
|
||||
log.Debugf("HKEY_LOCAL_MACHINE\\%s does not exist", path)
|
||||
return
|
||||
case err != nil:
|
||||
fmt.Fprintf(sb, "%serror: open HKEY_LOCAL_MACHINE\\%s: %v\n", indent, path, err)
|
||||
return
|
||||
}
|
||||
defer closeKey(k)
|
||||
|
||||
if names == nil {
|
||||
names, err = k.ReadValueNames(-1)
|
||||
if err != nil {
|
||||
fmt.Fprintf(sb, "%serror: read value names: %v\n", indent, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
for _, name := range names {
|
||||
value, err := readRegistryValue(k, name)
|
||||
switch {
|
||||
case errors.Is(err, registry.ErrNotExist):
|
||||
// the caller asks for a fixed set of values, most of which a
|
||||
// given interface does not carry
|
||||
continue
|
||||
case err != nil:
|
||||
// report rather than omit: a value that is there but cannot be
|
||||
// read reads as unset otherwise
|
||||
fmt.Fprintf(sb, "%s%s: error: %v\n", indent, name, err)
|
||||
continue
|
||||
}
|
||||
|
||||
fmt.Fprintf(sb, "%s%s: %s\n", indent, name, g.anonymizeValue(name, value))
|
||||
}
|
||||
}
|
||||
|
||||
// anonymizeValue redacts a registry value according to what its name says it
|
||||
// holds. Domains and addresses are handled per entry rather than by the string
|
||||
// pass: the pass only replaces domains something else in the bundle already
|
||||
// seeded, and its address regex would eat the digit labels of a reverse zone.
|
||||
func (g *BundleGenerator) anonymizeValue(name, value string) string {
|
||||
if !g.anonymize || value == "" {
|
||||
return value
|
||||
}
|
||||
|
||||
switch {
|
||||
case holdsDomains(name):
|
||||
return joinValueEntries(splitValueEntries(value), g.anonymizeDomain)
|
||||
case holdsAddresses(name):
|
||||
return joinValueEntries(splitValueEntries(value), g.anonymizer.AnonymizeIPString)
|
||||
default:
|
||||
return g.anonymizer.AnonymizeString(value)
|
||||
}
|
||||
}
|
||||
|
||||
// holdsDomains reports whether a value name holds domains: the domain list of
|
||||
// an NRPT rule (Name) or of the policy table (Namespace), a search list, the
|
||||
// DNS suffix values of the TCP/IP and policy keys, which all end in "Domain"
|
||||
// (Domain, DhcpDomain, NV Domain, ICSDomain), and a proxy host name.
|
||||
func holdsDomains(name string) bool {
|
||||
lower := strings.ToLower(name)
|
||||
return lower == "name" || lower == "namespace" || lower == "searchlist" ||
|
||||
strings.HasSuffix(lower, "domain") || strings.HasSuffix(lower, "proxyname")
|
||||
}
|
||||
|
||||
// holdsAddresses reports whether a value name holds DNS server addresses
|
||||
// (NameServer, DhcpNameServer, GenericDNSServers, NameServers).
|
||||
func holdsAddresses(name string) bool {
|
||||
lower := strings.ToLower(name)
|
||||
return strings.Contains(lower, "nameserver") || strings.Contains(lower, "dnsserver")
|
||||
}
|
||||
|
||||
// adapterNames maps adapter GUIDs, as the registry keys the interfaces, to the
|
||||
// names an operator sees.
|
||||
func adapterNames(adapters []*windows.IpAdapterAddresses) map[string]string {
|
||||
names := make(map[string]string, len(adapters))
|
||||
for _, adapter := range adapters {
|
||||
guid := windows.BytePtrToString(adapter.AdapterName)
|
||||
names[strings.ToLower(guid)] = windows.UTF16PtrToString(adapter.FriendlyName)
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
// writeAdapterDNS reports the resolver configuration in effect per adapter,
|
||||
// which is what the resolver uses for a name no NRPT rule matches.
|
||||
func (g *BundleGenerator) writeAdapterDNS(sb *strings.Builder, adapters []*windows.IpAdapterAddresses, err error) {
|
||||
writeSection(sb, "Adapter DNS configuration", "GetAdaptersAddresses")
|
||||
|
||||
if err != nil {
|
||||
fmt.Fprintf(sb, "error: %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
for _, adapter := range adapters {
|
||||
name := windows.UTF16PtrToString(adapter.FriendlyName)
|
||||
suffix := g.anonymizeDomain(windows.UTF16PtrToString(adapter.DnsSuffix))
|
||||
|
||||
fmt.Fprintf(sb, "%s (index %d, oper status %d)\n", name, adapter.IfIndex, adapter.OperStatus)
|
||||
fmt.Fprintf(sb, " DNS suffix: %s\n", suffix)
|
||||
|
||||
var servers []string
|
||||
for server := adapter.FirstDnsServerAddress; server != nil; server = server.Next {
|
||||
addr, ok := netip.AddrFromSlice(server.Address.IP())
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
addr = addr.Unmap()
|
||||
if g.anonymize {
|
||||
addr = g.anonymizer.AnonymizeIP(addr)
|
||||
}
|
||||
servers = append(servers, addr.String())
|
||||
}
|
||||
|
||||
fmt.Fprintf(sb, " DNS servers: %s\n", strings.Join(servers, ", "))
|
||||
}
|
||||
}
|
||||
|
||||
// anonymizeDomain anonymizes a single domain, keeping the leading dot an NRPT
|
||||
// match domain carries.
|
||||
func (g *BundleGenerator) anonymizeDomain(entry string) string {
|
||||
if !g.anonymize {
|
||||
return entry
|
||||
}
|
||||
|
||||
domain, dot := strings.CutPrefix(entry, ".")
|
||||
if domain == "" {
|
||||
return entry
|
||||
}
|
||||
|
||||
anonymized := g.anonymizer.AnonymizeDomain(domain)
|
||||
if dot {
|
||||
anonymized = "." + anonymized
|
||||
}
|
||||
return anonymized
|
||||
}
|
||||
|
||||
// splitValueEntries splits a registry value that holds a list. The separator
|
||||
// differs per value: a REG_MULTI_SZ arrives joined with ", ", a SearchList is
|
||||
// comma separated and a NameServer may use commas or spaces.
|
||||
func splitValueEntries(value string) []string {
|
||||
return strings.FieldsFunc(value, func(r rune) bool {
|
||||
return r == ',' || r == ';' || r == ' ' || r == '\t'
|
||||
})
|
||||
}
|
||||
|
||||
func joinValueEntries(entries []string, anonymize func(string) string) string {
|
||||
for i, entry := range entries {
|
||||
entries[i] = anonymize(entry)
|
||||
}
|
||||
return strings.Join(entries, ", ")
|
||||
}
|
||||
|
||||
func writeSection(sb *strings.Builder, title, source string) {
|
||||
fmt.Fprintf(sb, "\n%s\n%s\n%s\n", title, strings.Repeat("-", len(title)), source)
|
||||
}
|
||||
|
||||
func subKeyNames(root string) ([]string, error) {
|
||||
k, err := registry.OpenKey(registry.LOCAL_MACHINE, root, registry.ENUMERATE_SUB_KEYS)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open HKEY_LOCAL_MACHINE\\%s: %w", root, err)
|
||||
}
|
||||
defer closeKey(k)
|
||||
|
||||
names, err := k.ReadSubKeyNames(-1)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read subkey names: %w", err)
|
||||
}
|
||||
|
||||
return names, nil
|
||||
}
|
||||
|
||||
// readRegistryValue renders a value as text regardless of its type, so an
|
||||
// unexpected type in a policy key still shows up instead of being dropped.
|
||||
func readRegistryValue(k registry.Key, name string) (string, error) {
|
||||
_, valueType, err := k.GetValue(name, nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("get value %s: %w", name, err)
|
||||
}
|
||||
|
||||
switch valueType {
|
||||
case registry.SZ, registry.EXPAND_SZ:
|
||||
value, _, err := k.GetStringValue(name)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("get string value %s: %w", name, err)
|
||||
}
|
||||
return value, nil
|
||||
case registry.MULTI_SZ:
|
||||
values, _, err := k.GetStringsValue(name)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("get strings value %s: %w", name, err)
|
||||
}
|
||||
return strings.Join(values, ", "), nil
|
||||
case registry.DWORD, registry.QWORD:
|
||||
value, _, err := k.GetIntegerValue(name)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("get integer value %s: %w", name, err)
|
||||
}
|
||||
return fmt.Sprintf("%d (0x%x)", value, value), nil
|
||||
case registry.BINARY:
|
||||
value, _, err := k.GetBinaryValue(name)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("get binary value %s: %w", name, err)
|
||||
}
|
||||
return hex.EncodeToString(value), nil
|
||||
default:
|
||||
return fmt.Sprintf("<unhandled registry type %d>", valueType), nil
|
||||
}
|
||||
}
|
||||
|
||||
// adapterAddresses returns the adapter list including DNS servers. The call
|
||||
// reports the size it needs, so grow the buffer and retry until it fits.
|
||||
func adapterAddresses() (adapters []*windows.IpAdapterAddresses, err error) {
|
||||
// GetAdaptersAddresses is resolved on first use and panics when it is
|
||||
// missing, so this reports it as an error and leaves the rest of the
|
||||
// report intact.
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
adapters, err = nil, fmt.Errorf("GetAdaptersAddresses: %v", r)
|
||||
}
|
||||
}()
|
||||
|
||||
const flags = windows.GAA_FLAG_SKIP_ANYCAST | windows.GAA_FLAG_SKIP_MULTICAST
|
||||
|
||||
size := uint32(15000)
|
||||
for range 3 {
|
||||
buf := make([]byte, size)
|
||||
first := (*windows.IpAdapterAddresses)(unsafe.Pointer(&buf[0]))
|
||||
|
||||
err := windows.GetAdaptersAddresses(windows.AF_UNSPEC, flags, 0, first, &size)
|
||||
if errors.Is(err, windows.ERROR_BUFFER_OVERFLOW) {
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("GetAdaptersAddresses: %w", err)
|
||||
}
|
||||
|
||||
for adapter := first; adapter != nil; adapter = adapter.Next {
|
||||
adapters = append(adapters, adapter)
|
||||
}
|
||||
return adapters, nil
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("GetAdaptersAddresses: buffer kept growing")
|
||||
}
|
||||
|
||||
func closeKey(k registry.Key) {
|
||||
if err := k.Close(); err != nil {
|
||||
log.Debugf("close registry key: %v", err)
|
||||
}
|
||||
}
|
||||
146
client/internal/debug/debug_windows_test.go
Normal file
146
client/internal/debug/debug_windows_test.go
Normal file
@@ -0,0 +1,146 @@
|
||||
//go:build windows
|
||||
|
||||
package debug
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/netbirdio/netbird/client/anonymize"
|
||||
)
|
||||
|
||||
func newDNSValueGenerator(level anonymize.Level) *BundleGenerator {
|
||||
anonymizer := anonymize.NewAnonymizer(anonymize.DefaultAddresses())
|
||||
anonymizer.SetLevel(level)
|
||||
|
||||
return &BundleGenerator{
|
||||
anonymize: true,
|
||||
anonymizeLevel: level,
|
||||
anonymizer: anonymizer,
|
||||
}
|
||||
}
|
||||
|
||||
// TestAnonymizeValueByName covers the value kinds of the DNS registry keys. The
|
||||
// names decide the treatment, because the string pass alone replaces only
|
||||
// domains another part of the bundle already seeded.
|
||||
func TestAnonymizeValueByName(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
valueName string
|
||||
value string
|
||||
assert func(t *testing.T, got string)
|
||||
}{
|
||||
{
|
||||
name: "NRPT match domains keep the leading dot",
|
||||
valueName: "Name",
|
||||
value: ".internal.example.com, .corp.example.org",
|
||||
assert: func(t *testing.T, got string) {
|
||||
t.Helper()
|
||||
for _, entry := range strings.Split(got, ", ") {
|
||||
assert.True(t, strings.HasPrefix(entry, "."), "entry %q should keep its leading dot", entry)
|
||||
assert.NotContains(t, entry, "example", "entry %q should not keep the original domain", entry)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "any value name ending in Domain is treated as a domain",
|
||||
valueName: "ICSDomain",
|
||||
value: "mshome.net",
|
||||
assert: func(t *testing.T, got string) {
|
||||
t.Helper()
|
||||
assert.NotContains(t, got, "mshome", "should anonymize a domain suffix value")
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "search list is a comma separated domain list",
|
||||
valueName: "SearchList",
|
||||
value: "corp.example.com,branch.example.com",
|
||||
assert: func(t *testing.T, got string) {
|
||||
t.Helper()
|
||||
assert.NotContains(t, got, "example", "should anonymize every search domain")
|
||||
assert.Len(t, strings.Split(got, ", "), 2, "should keep both search domains")
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "name servers are anonymized as addresses",
|
||||
valueName: "DhcpNameServer",
|
||||
value: "203.0.113.10 8.8.8.8",
|
||||
assert: func(t *testing.T, got string) {
|
||||
t.Helper()
|
||||
assert.NotContains(t, got, "203.0.113.10", "should anonymize a public resolver address")
|
||||
// well-known resolvers stay readable at every level
|
||||
assert.Contains(t, got, "8.8.8.8", "should keep a well-known resolver address")
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "opaque values are left to the string pass",
|
||||
valueName: "DataBasePath",
|
||||
value: `%SystemRoot%\System32\drivers\etc`,
|
||||
assert: func(t *testing.T, got string) {
|
||||
t.Helper()
|
||||
assert.Equal(t, `%SystemRoot%\System32\drivers\etc`, got, "should not alter a path")
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
g := newDNSValueGenerator(anonymize.LevelDefault)
|
||||
tc.assert(t, g.anonymizeValue(tc.valueName, tc.value))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseNRPTPolicyTable parses the MOF text of the policy table out
|
||||
// parameters, as the provider on a client with one NRPT rule renders it.
|
||||
func TestParseNRPTPolicyTable(t *testing.T) {
|
||||
const text = `[abstract]
|
||||
class __PARAMETERS
|
||||
{
|
||||
[Out, EmbeddedInstance("DnsClientPolicyConfiguration"): ToSubClass, ID(2): DisableOverride ToInstance] DnsClientPolicyConfiguration cmdletOutput[] = {
|
||||
instance of DnsClientPolicyConfiguration
|
||||
{
|
||||
DirectAccessProxyType = "NoProxy";
|
||||
DirectAccessQueryIPsecRequired = FALSE;
|
||||
NameEncoding = "Utf8WithoutMapping";
|
||||
Namespace = ".0.100.in-addr.arpa";
|
||||
},
|
||||
instance of DnsClientPolicyConfiguration
|
||||
{
|
||||
DirectAccessProxyType = "NoProxy";
|
||||
NameEncoding = "Utf8WithoutMapping";
|
||||
NameServers = {"100.0.255.254", "100.0.255.253"};
|
||||
Namespace = ".nb.internal";
|
||||
}};
|
||||
[in] boolean Effective;
|
||||
[out] uint32 ReturnValue = 0;
|
||||
};
|
||||
`
|
||||
|
||||
entries := parseNRPTPolicyTable(text)
|
||||
require.Len(t, entries, 2, "should parse both embedded instances")
|
||||
|
||||
assert.Equal(t, ".0.100.in-addr.arpa", entries[0].namespace, "should read the namespace of the first instance")
|
||||
assert.Equal(t, ".nb.internal", entries[1].namespace, "should read the namespace of the second instance")
|
||||
|
||||
assert.Equal(t, []registryValue{
|
||||
{name: "DirectAccessProxyType", value: "NoProxy"},
|
||||
{name: "DirectAccessQueryIPsecRequired", value: "FALSE"},
|
||||
{name: "NameEncoding", value: "Utf8WithoutMapping"},
|
||||
}, entries[0].values, "should keep the remaining values in order")
|
||||
|
||||
assert.Contains(t, entries[1].values, registryValue{name: "NameServers", value: "100.0.255.254, 100.0.255.253"},
|
||||
"should flatten a MOF array")
|
||||
|
||||
for _, value := range entries[1].values {
|
||||
assert.NotContains(t, value.name, "ReturnValue", "should not read the class level parameters as values")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseNRPTPolicyTableEmpty(t *testing.T) {
|
||||
assert.Empty(t, parseNRPTPolicyTable(""), "should parse no entries from empty text")
|
||||
assert.Empty(t, parseNRPTPolicyTable("class __PARAMETERS\n{\n};\n"), "should parse no entries from a table with no instances")
|
||||
}
|
||||
317
client/internal/debug/nrpt_windows.go
Normal file
317
client/internal/debug/nrpt_windows.go
Normal file
@@ -0,0 +1,317 @@
|
||||
//go:build windows
|
||||
|
||||
package debug
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-ole/go-ole"
|
||||
"github.com/go-ole/go-ole/oleutil"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
const (
|
||||
// The NRPT policy table is reachable through the CIM class that backs
|
||||
// Get-DnsClientNrptPolicy. Unlike the rules in the registry, the table is
|
||||
// what the resolver currently has loaded, which is the only way to tell an
|
||||
// applied rule from one that is merely written, in either direction.
|
||||
nrptPolicyNamespace = `root\Microsoft\Windows\DNS`
|
||||
nrptPolicyClass = "PS_DnsClientNrptPolicy"
|
||||
nrptPolicyMethod = "Get"
|
||||
|
||||
// The class has no instances, so the table comes from the out parameters
|
||||
// of a static method call, rendered as MOF text: the embedded instances
|
||||
// arrive as a safe array of objects, which cannot be read back through the
|
||||
// COM bindings, and the text form carries all of them.
|
||||
nrptPolicyInstanceKeyword = "instance of DnsClientPolicyConfiguration"
|
||||
|
||||
nrptPolicyTimeout = 15 * time.Second
|
||||
)
|
||||
|
||||
// COM initialization results that leave the calling thread usable: S_FALSE for
|
||||
// a thread this process already initialized, RPC_E_CHANGED_MODE for one that
|
||||
// belongs to another apartment.
|
||||
const (
|
||||
sFalse = 0x00000001
|
||||
rpcEChangedMode = 0x80010106
|
||||
)
|
||||
|
||||
// nrptQueryInFlight admits one read of the policy table at a time. A provider
|
||||
// that stops answering keeps its goroutine and the OS thread that goroutine
|
||||
// pinned, so a later bundle reports that instead of pinning another one.
|
||||
var nrptQueryInFlight = make(chan struct{}, 1)
|
||||
|
||||
// nrptPolicyEntry is one namespace of the effective policy table, holding the
|
||||
// values of an embedded DnsClientPolicyConfiguration instance in the order the
|
||||
// provider reported them.
|
||||
type nrptPolicyEntry struct {
|
||||
namespace string
|
||||
values []registryValue
|
||||
}
|
||||
|
||||
// registryValue is a name and its rendered value, shared by the registry and
|
||||
// policy table readers so both anonymize by value name the same way.
|
||||
type registryValue struct {
|
||||
name string
|
||||
value string
|
||||
}
|
||||
|
||||
// effectiveNRPTPolicies reads the effective NRPT table. The call is bounded
|
||||
// because a WMI provider can block indefinitely and a debug bundle must not.
|
||||
func effectiveNRPTPolicies() ([]nrptPolicyEntry, error) {
|
||||
type result struct {
|
||||
text string
|
||||
err error
|
||||
}
|
||||
|
||||
select {
|
||||
case nrptQueryInFlight <- struct{}{}:
|
||||
default:
|
||||
return nil, errors.New("an earlier read of the policy table has not returned")
|
||||
}
|
||||
|
||||
done := make(chan result, 1)
|
||||
go func() {
|
||||
// the slot is released here rather than by the caller, so a read that
|
||||
// outlives the timeout holds it until the provider answers
|
||||
defer func() { <-nrptQueryInFlight }()
|
||||
|
||||
text, err := nrptPolicyTableText()
|
||||
done <- result{text: text, err: err}
|
||||
}()
|
||||
|
||||
select {
|
||||
case res := <-done:
|
||||
if res.err != nil {
|
||||
return nil, res.err
|
||||
}
|
||||
return parseNRPTPolicyTable(res.text), nil
|
||||
case <-time.After(nrptPolicyTimeout):
|
||||
return nil, errors.New("read of the policy table timed out")
|
||||
}
|
||||
}
|
||||
|
||||
// nrptPolicyTableText calls the policy table method and returns the MOF text of
|
||||
// its out parameters.
|
||||
func nrptPolicyTableText() (text string, err error) {
|
||||
// COM is per thread, and the collection is short lived, so the thread is
|
||||
// pinned for the duration rather than initialized for the process.
|
||||
runtime.LockOSThread()
|
||||
defer runtime.UnlockOSThread()
|
||||
|
||||
defer func() {
|
||||
// The COM call chain is dynamically typed, so a provider that answers
|
||||
// with an unexpected shape must not take the daemon down with it.
|
||||
if r := recover(); r != nil {
|
||||
err = fmt.Errorf("read NRPT policy table: %v", r)
|
||||
}
|
||||
}()
|
||||
|
||||
owns, err := coInitialize()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if owns {
|
||||
defer ole.CoUninitialize()
|
||||
}
|
||||
|
||||
locator, err := oleutil.CreateObject("WbemScripting.SWbemLocator")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("create WMI locator: %w", err)
|
||||
}
|
||||
defer locator.Release()
|
||||
|
||||
dispatch, err := locator.QueryInterface(ole.IID_IDispatch)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("query WMI locator interface: %w", err)
|
||||
}
|
||||
defer dispatch.Release()
|
||||
|
||||
service, err := dispatchCall(dispatch, "ConnectServer", nil, nrptPolicyNamespace)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("connect to %s: %w", nrptPolicyNamespace, err)
|
||||
}
|
||||
defer service.Release()
|
||||
|
||||
inParams, err := spawnMethodInParams(service)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer inParams.Release()
|
||||
|
||||
// The effective table is the merge of the local and the group policy
|
||||
// store, which is what the resolver answers from.
|
||||
if _, err := oleutil.PutProperty(inParams, "Effective", true); err != nil {
|
||||
return "", fmt.Errorf("set Effective parameter: %w", err)
|
||||
}
|
||||
|
||||
outParams, err := dispatchCall(service, "ExecMethod", nrptPolicyClass, nrptPolicyMethod, inParams)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("call %s.%s: %w", nrptPolicyClass, nrptPolicyMethod, err)
|
||||
}
|
||||
defer outParams.Release()
|
||||
|
||||
textVariant, err := oleutil.CallMethod(outParams, "GetObjectText_")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("render policy table: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
if err := textVariant.Clear(); err != nil {
|
||||
log.Debugf("clear policy table variant: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
return textVariant.ToString(), nil
|
||||
}
|
||||
|
||||
// spawnMethodInParams builds the in parameters instance the method needs. The
|
||||
// provider rejects the call without one, even when every parameter is optional.
|
||||
func spawnMethodInParams(service *ole.IDispatch) (*ole.IDispatch, error) {
|
||||
class, err := dispatchCall(service, "Get", nrptPolicyClass)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get class %s: %w", nrptPolicyClass, err)
|
||||
}
|
||||
defer class.Release()
|
||||
|
||||
methods, err := dispatchProperty(class, "Methods_")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get class methods: %w", err)
|
||||
}
|
||||
defer methods.Release()
|
||||
|
||||
method, err := dispatchCall(methods, "Item", nrptPolicyMethod)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get method %s: %w", nrptPolicyMethod, err)
|
||||
}
|
||||
defer method.Release()
|
||||
|
||||
params, err := dispatchProperty(method, "InParameters")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get method parameters: %w", err)
|
||||
}
|
||||
defer params.Release()
|
||||
|
||||
inParams, err := dispatchCall(params, "SpawnInstance_")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("spawn parameter instance: %w", err)
|
||||
}
|
||||
|
||||
return inParams, nil
|
||||
}
|
||||
|
||||
// parseNRPTPolicyTable pulls the embedded instances out of the MOF text. Each
|
||||
// instance is a namespace of the table, with one name and value per line.
|
||||
func parseNRPTPolicyTable(text string) []nrptPolicyEntry {
|
||||
var entries []nrptPolicyEntry
|
||||
var current *nrptPolicyEntry
|
||||
|
||||
for _, line := range strings.Split(text, "\n") {
|
||||
line = strings.TrimSpace(strings.TrimSuffix(strings.TrimSpace(line), ";"))
|
||||
|
||||
switch {
|
||||
case strings.HasPrefix(line, nrptPolicyInstanceKeyword):
|
||||
entries = append(entries, nrptPolicyEntry{})
|
||||
current = &entries[len(entries)-1]
|
||||
continue
|
||||
case strings.HasPrefix(line, "}"):
|
||||
// closes an instance, and the array with the last one, so the
|
||||
// class level parameters that follow are not read as values
|
||||
current = nil
|
||||
continue
|
||||
case current == nil, line == "{":
|
||||
continue
|
||||
}
|
||||
|
||||
name, value, ok := strings.Cut(line, " = ")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
value = unquoteMOFValue(value)
|
||||
if name == "Namespace" {
|
||||
current.namespace = value
|
||||
continue
|
||||
}
|
||||
|
||||
current.values = append(current.values, registryValue{name: name, value: value})
|
||||
}
|
||||
|
||||
return entries
|
||||
}
|
||||
|
||||
// unquoteMOFValue renders a MOF scalar or array as plain text: "a" becomes a,
|
||||
// and {"a", "b"} becomes a, b.
|
||||
func unquoteMOFValue(value string) string {
|
||||
value = strings.TrimSpace(value)
|
||||
|
||||
if inner, ok := strings.CutPrefix(value, "{"); ok {
|
||||
value = strings.TrimSuffix(inner, "}")
|
||||
|
||||
entries := strings.Split(value, ",")
|
||||
for i, entry := range entries {
|
||||
entries[i] = strings.Trim(strings.TrimSpace(entry), `"`)
|
||||
}
|
||||
return strings.Join(entries, ", ")
|
||||
}
|
||||
|
||||
return strings.Trim(value, `"`)
|
||||
}
|
||||
|
||||
// coInitialize prepares the calling thread for COM and reports whether this
|
||||
// call owns the initialization, which decides whether it may be balanced with
|
||||
// CoUninitialize. S_FALSE took a reference on a thread this process had already
|
||||
// initialized and so has to be released, while RPC_E_CHANGED_MODE took none:
|
||||
// the thread belongs to another apartment, which is usable but is not ours to
|
||||
// uninitialize.
|
||||
func coInitialize() (bool, error) {
|
||||
err := ole.CoInitializeEx(0, ole.COINIT_MULTITHREADED)
|
||||
if err == nil {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
var oleErr *ole.OleError
|
||||
if errors.As(err, &oleErr) {
|
||||
switch oleErr.Code() {
|
||||
case sFalse:
|
||||
return true, nil
|
||||
case rpcEChangedMode:
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
|
||||
return false, fmt.Errorf("initialize COM: %w", err)
|
||||
}
|
||||
|
||||
// dispatchCall calls a COM method that returns an object.
|
||||
func dispatchCall(dispatch *ole.IDispatch, method string, params ...any) (*ole.IDispatch, error) {
|
||||
variant, err := oleutil.CallMethod(dispatch, method, params...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
object := variant.ToIDispatch()
|
||||
if object == nil {
|
||||
return nil, fmt.Errorf("%s returned no object", method)
|
||||
}
|
||||
|
||||
return object, nil
|
||||
}
|
||||
|
||||
// dispatchProperty reads a COM property that holds an object.
|
||||
func dispatchProperty(dispatch *ole.IDispatch, property string) (*ole.IDispatch, error) {
|
||||
variant, err := oleutil.GetProperty(dispatch, property)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
object := variant.ToIDispatch()
|
||||
if object == nil {
|
||||
return nil, fmt.Errorf("property %s holds no object", property)
|
||||
}
|
||||
|
||||
return object, nil
|
||||
}
|
||||
@@ -35,14 +35,14 @@ func (g *BundleGenerator) toWGShowFormat(s *configurer.Stats) string {
|
||||
var sb strings.Builder
|
||||
|
||||
sb.WriteString(fmt.Sprintf("interface: %s\n", s.DeviceName))
|
||||
sb.WriteString(fmt.Sprintf(" public key: %s\n", s.PublicKey))
|
||||
sb.WriteString(fmt.Sprintf(" public key: %s\n", g.anonymizer.AnonymizeWGKey(s.PublicKey)))
|
||||
sb.WriteString(fmt.Sprintf(" listen port: %d\n", s.ListenPort))
|
||||
if s.FWMark != 0 {
|
||||
sb.WriteString(fmt.Sprintf(" fwmark: %#x\n", s.FWMark))
|
||||
}
|
||||
|
||||
for _, peer := range s.Peers {
|
||||
sb.WriteString(fmt.Sprintf("\npeer: %s\n", peer.PublicKey))
|
||||
sb.WriteString(fmt.Sprintf("\npeer: %s\n", g.anonymizer.AnonymizeWGKey(peer.PublicKey)))
|
||||
if peer.Endpoint.IP != nil {
|
||||
if g.anonymize {
|
||||
anonEndpoint := g.anonymizer.AnonymizeUDPAddr(peer.Endpoint)
|
||||
@@ -54,7 +54,11 @@ func (g *BundleGenerator) toWGShowFormat(s *configurer.Stats) string {
|
||||
if len(peer.AllowedIPs) > 0 {
|
||||
var ipStrings []string
|
||||
for _, ipnet := range peer.AllowedIPs {
|
||||
ipStrings = append(ipStrings, ipnet.String())
|
||||
ipStr := ipnet.String()
|
||||
if g.anonymize {
|
||||
ipStr = g.anonymizer.AnonymizeIPString(ipStr)
|
||||
}
|
||||
ipStrings = append(ipStrings, ipStr)
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf(" allowed ips: %s\n", strings.Join(ipStrings, ", ")))
|
||||
}
|
||||
|
||||
@@ -267,18 +267,38 @@ func (s *systemConfigurator) getSystemDNSSettings() (SystemDNSSettings, error) {
|
||||
return SystemDNSSettings{}, fmt.Errorf("sending the command: %w", err)
|
||||
}
|
||||
|
||||
var dnsSettings SystemDNSSettings
|
||||
dnsSettings, serverAddresses, err := parseSystemDNSSettings(b)
|
||||
if err != nil {
|
||||
return dnsSettings, err
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
s.origNameservers = serverAddresses
|
||||
s.mu.Unlock()
|
||||
|
||||
return dnsSettings, nil
|
||||
}
|
||||
|
||||
// parseSystemDNSSettings parses the output of `scutil show State:/Network/Service/<id>/DNS`.
|
||||
// Lines that don't match the expected "index : value" shape are skipped: hosts with unusual
|
||||
// network services (e.g. orphaned hardware ports) can produce entries without a value.
|
||||
func parseSystemDNSSettings(out []byte) (SystemDNSSettings, []netip.Addr, error) {
|
||||
// port is not exposed by scutil, default to 53
|
||||
dnsSettings := SystemDNSSettings{ServerPort: DefaultPort}
|
||||
var serverAddresses []netip.Addr
|
||||
inSearchDomainsArray := false
|
||||
inServerAddressesArray := false
|
||||
|
||||
scanner := bufio.NewScanner(bytes.NewReader(b))
|
||||
scanner := bufio.NewScanner(bytes.NewReader(out))
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
switch {
|
||||
case strings.HasPrefix(line, "DomainName :"):
|
||||
domainName := strings.TrimSpace(strings.Split(line, ":")[1])
|
||||
dnsSettings.Domains = append(dnsSettings.Domains, domainName)
|
||||
domainName := strings.TrimSpace(strings.TrimPrefix(line, "DomainName :"))
|
||||
if domainName != "" {
|
||||
dnsSettings.Domains = append(dnsSettings.Domains, domainName)
|
||||
}
|
||||
continue
|
||||
case line == "SearchDomains : <array> {":
|
||||
inSearchDomainsArray = true
|
||||
continue
|
||||
@@ -288,36 +308,45 @@ func (s *systemConfigurator) getSystemDNSSettings() (SystemDNSSettings, error) {
|
||||
case line == "}":
|
||||
inSearchDomainsArray = false
|
||||
inServerAddressesArray = false
|
||||
continue
|
||||
}
|
||||
|
||||
if !inSearchDomainsArray && !inServerAddressesArray {
|
||||
continue
|
||||
}
|
||||
|
||||
parts := strings.SplitN(line, " : ", 2)
|
||||
if len(parts) != 2 {
|
||||
log.Debugf("skipping unexpected scutil DNS line %q", line)
|
||||
continue
|
||||
}
|
||||
value := strings.TrimSpace(parts[1])
|
||||
if value == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
if inSearchDomainsArray {
|
||||
searchDomain := strings.Split(line, " : ")[1]
|
||||
dnsSettings.Domains = append(dnsSettings.Domains, searchDomain)
|
||||
} else if inServerAddressesArray {
|
||||
address := strings.Split(line, " : ")[1]
|
||||
if ip, err := netip.ParseAddr(address); err == nil && !ip.IsUnspecified() {
|
||||
ip = ip.Unmap()
|
||||
serverAddresses = append(serverAddresses, ip)
|
||||
// Prefer the first IPv4 server as ServerIP since our DNS listener is IPv4.
|
||||
if !dnsSettings.ServerIP.IsValid() && ip.Is4() {
|
||||
dnsSettings.ServerIP = ip
|
||||
}
|
||||
}
|
||||
dnsSettings.Domains = append(dnsSettings.Domains, value)
|
||||
continue
|
||||
}
|
||||
|
||||
ip, err := netip.ParseAddr(value)
|
||||
if err != nil || ip.IsUnspecified() {
|
||||
continue
|
||||
}
|
||||
ip = ip.Unmap()
|
||||
serverAddresses = append(serverAddresses, ip)
|
||||
// Prefer the first IPv4 server as ServerIP since our DNS listener is IPv4.
|
||||
if !dnsSettings.ServerIP.IsValid() && ip.Is4() {
|
||||
dnsSettings.ServerIP = ip
|
||||
}
|
||||
}
|
||||
|
||||
if err := scanner.Err(); err != nil {
|
||||
return dnsSettings, err
|
||||
return dnsSettings, serverAddresses, err
|
||||
}
|
||||
|
||||
// default to 53 port
|
||||
dnsSettings.ServerPort = DefaultPort
|
||||
|
||||
s.mu.Lock()
|
||||
s.origNameservers = serverAddresses
|
||||
s.mu.Unlock()
|
||||
|
||||
return dnsSettings, nil
|
||||
return dnsSettings, serverAddresses, nil
|
||||
}
|
||||
|
||||
func (s *systemConfigurator) getOriginalNameservers() []netip.Addr {
|
||||
@@ -435,11 +464,15 @@ func (s *systemConfigurator) getPrimaryService() (string, string, error) {
|
||||
router := ""
|
||||
for scanner.Scan() {
|
||||
text := scanner.Text()
|
||||
parts := strings.SplitN(text, ":", 2)
|
||||
if len(parts) != 2 {
|
||||
continue
|
||||
}
|
||||
if strings.Contains(text, "PrimaryService") {
|
||||
primaryService = strings.TrimSpace(strings.Split(text, ":")[1])
|
||||
primaryService = strings.TrimSpace(parts[1])
|
||||
}
|
||||
if strings.Contains(text, "Router") {
|
||||
router = strings.TrimSpace(strings.Split(text, ":")[1])
|
||||
router = strings.TrimSpace(parts[1])
|
||||
}
|
||||
}
|
||||
if err := scanner.Err(); err != nil && err != io.EOF {
|
||||
|
||||
@@ -328,6 +328,120 @@ func removeTestDNSKey(key string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
func TestParseSystemDNSSettings(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
output string
|
||||
expectedDomains []string
|
||||
expectedServers []netip.Addr
|
||||
expectedIP netip.Addr
|
||||
}{
|
||||
{
|
||||
name: "well_formed",
|
||||
output: `<dictionary> {
|
||||
DomainName : example.com
|
||||
SearchDomains : <array> {
|
||||
0 : example.com
|
||||
1 : corp.example.com
|
||||
}
|
||||
ServerAddresses : <array> {
|
||||
0 : 192.168.1.1
|
||||
1 : fd00::53
|
||||
}
|
||||
}
|
||||
`,
|
||||
expectedDomains: []string{"example.com", "example.com", "corp.example.com"},
|
||||
expectedServers: []netip.Addr{netip.MustParseAddr("192.168.1.1"), netip.MustParseAddr("fd00::53")},
|
||||
expectedIP: netip.MustParseAddr("192.168.1.1"),
|
||||
},
|
||||
{
|
||||
// entries without a value after the separator used to panic with
|
||||
// "index out of range [1] with length 1"
|
||||
name: "malformed_array_entries_skipped",
|
||||
output: `<dictionary> {
|
||||
SearchDomains : <array> {
|
||||
0 :
|
||||
(null)
|
||||
|
||||
1 : corp.example.com
|
||||
}
|
||||
ServerAddresses : <array> {
|
||||
0 :
|
||||
1 : 192.168.1.1
|
||||
}
|
||||
}
|
||||
`,
|
||||
expectedDomains: []string{"corp.example.com"},
|
||||
expectedServers: []netip.Addr{netip.MustParseAddr("192.168.1.1")},
|
||||
expectedIP: netip.MustParseAddr("192.168.1.1"),
|
||||
},
|
||||
{
|
||||
name: "domain_name_without_value_skipped",
|
||||
output: `<dictionary> {
|
||||
DomainName :
|
||||
ServerAddresses : <array> {
|
||||
0 : 192.168.1.1
|
||||
}
|
||||
}
|
||||
`,
|
||||
expectedServers: []netip.Addr{netip.MustParseAddr("192.168.1.1")},
|
||||
expectedIP: netip.MustParseAddr("192.168.1.1"),
|
||||
},
|
||||
{
|
||||
name: "ipv6_first_prefers_ipv4_server_ip",
|
||||
output: `<dictionary> {
|
||||
ServerAddresses : <array> {
|
||||
0 : fd00::53
|
||||
1 : 192.168.1.1
|
||||
}
|
||||
}
|
||||
`,
|
||||
expectedServers: []netip.Addr{netip.MustParseAddr("fd00::53"), netip.MustParseAddr("192.168.1.1")},
|
||||
expectedIP: netip.MustParseAddr("192.168.1.1"),
|
||||
},
|
||||
{
|
||||
name: "invalid_and_unspecified_addresses_skipped",
|
||||
output: `<dictionary> {
|
||||
ServerAddresses : <array> {
|
||||
0 : (null)
|
||||
1 : 0.0.0.0
|
||||
2 : 192.168.1.1
|
||||
}
|
||||
}
|
||||
`,
|
||||
expectedServers: []netip.Addr{netip.MustParseAddr("192.168.1.1")},
|
||||
expectedIP: netip.MustParseAddr("192.168.1.1"),
|
||||
},
|
||||
{
|
||||
name: "v4_mapped_address_unmapped",
|
||||
output: `<dictionary> {
|
||||
ServerAddresses : <array> {
|
||||
0 : ::ffff:192.168.1.1
|
||||
}
|
||||
}
|
||||
`,
|
||||
expectedServers: []netip.Addr{netip.MustParseAddr("192.168.1.1")},
|
||||
expectedIP: netip.MustParseAddr("192.168.1.1"),
|
||||
},
|
||||
{
|
||||
name: "empty_output",
|
||||
output: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
settings, servers, err := parseSystemDNSSettings([]byte(tc.output))
|
||||
require.NoError(t, err, "parsing should not fail")
|
||||
|
||||
assert.Equal(t, tc.expectedDomains, settings.Domains, "domains should match")
|
||||
assert.Equal(t, tc.expectedServers, servers, "server addresses should match")
|
||||
assert.Equal(t, tc.expectedIP, settings.ServerIP, "server IP should match")
|
||||
assert.Equal(t, DefaultPort, settings.ServerPort, "server port should default to 53")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetOriginalNameservers(t *testing.T) {
|
||||
configurator := &systemConfigurator{
|
||||
createdKeys: make(map[string]struct{}),
|
||||
|
||||
@@ -31,10 +31,28 @@ var (
|
||||
dnsFlushResolverCacheFn = dnsapi.NewProc("DnsFlushResolverCache")
|
||||
)
|
||||
|
||||
// Registry locations of the host DNS configuration this package programs,
|
||||
// exported so a diagnostic reader reports the same locations that are written.
|
||||
const (
|
||||
dnsPolicyConfigMatchPath = `SYSTEM\CurrentControlSet\Services\Dnscache\Parameters\DnsPolicyConfig\NetBird-Match`
|
||||
gpoDnsPolicyRoot = `SOFTWARE\Policies\Microsoft\Windows NT\DNSClient\DnsPolicyConfig`
|
||||
gpoDnsPolicyConfigMatchPath = gpoDnsPolicyRoot + `\NetBird-Match`
|
||||
// NRPTKeyPrefix starts the name of every NRPT rule key this client creates.
|
||||
NRPTKeyPrefix = "NetBird-Match"
|
||||
|
||||
// DNSPolicyConfigRoot holds the NRPT rules of the local policy store.
|
||||
DNSPolicyConfigRoot = `SYSTEM\CurrentControlSet\Services\Dnscache\Parameters\DnsPolicyConfig`
|
||||
|
||||
// GPODNSPolicyConfigRoot holds the NRPT rules of the group policy store,
|
||||
// which takes precedence over the local one when it is present.
|
||||
GPODNSPolicyConfigRoot = `SOFTWARE\Policies\Microsoft\Windows NT\DNSClient\DnsPolicyConfig`
|
||||
|
||||
// InterfaceConfigPath and InterfaceConfigPathV6 hold the per-interface DNS
|
||||
// settings, keyed by interface GUID, in separate hives per address family.
|
||||
InterfaceConfigPath = `SYSTEM\CurrentControlSet\Services\Tcpip\Parameters\Interfaces`
|
||||
InterfaceConfigPathV6 = `SYSTEM\CurrentControlSet\Services\Tcpip6\Parameters\Interfaces`
|
||||
)
|
||||
|
||||
const (
|
||||
dnsPolicyConfigMatchPath = DNSPolicyConfigRoot + `\` + NRPTKeyPrefix
|
||||
gpoDnsPolicyConfigMatchPath = GPODNSPolicyConfigRoot + `\` + NRPTKeyPrefix
|
||||
|
||||
dnsPolicyConfigVersionKey = "Version"
|
||||
dnsPolicyConfigVersionValue = 2
|
||||
@@ -45,8 +63,6 @@ const (
|
||||
|
||||
nrptMaxDomainsPerRule = 50
|
||||
|
||||
interfaceConfigPath = `SYSTEM\CurrentControlSet\Services\Tcpip\Parameters\Interfaces`
|
||||
interfaceConfigPathV6 = `SYSTEM\CurrentControlSet\Services\Tcpip6\Parameters\Interfaces`
|
||||
interfaceConfigNameServerKey = "NameServer"
|
||||
interfaceConfigDhcpNameSrvKey = "DhcpNameServer"
|
||||
interfaceConfigSearchListKey = "SearchList"
|
||||
@@ -84,7 +100,7 @@ func newHostManager(wgInterface WGIface) (*registryConfigurator, error) {
|
||||
}
|
||||
|
||||
var useGPO bool
|
||||
k, err := registry.OpenKey(registry.LOCAL_MACHINE, gpoDnsPolicyRoot, registry.QUERY_VALUE)
|
||||
k, err := registry.OpenKey(registry.LOCAL_MACHINE, GPODNSPolicyConfigRoot, registry.QUERY_VALUE)
|
||||
if err != nil {
|
||||
log.Debugf("failed to open GPO DNS policy root: %v", err)
|
||||
} else {
|
||||
@@ -123,7 +139,7 @@ func (r *registryConfigurator) captureOriginalNameservers() ([]netip.Addr, error
|
||||
seen := make(map[netip.Addr]struct{})
|
||||
var out []netip.Addr
|
||||
var merr *multierror.Error
|
||||
for _, root := range []string{interfaceConfigPath, interfaceConfigPathV6} {
|
||||
for _, root := range []string{InterfaceConfigPath, InterfaceConfigPathV6} {
|
||||
addrs, err := r.captureFromTcpipRoot(root)
|
||||
if err != nil {
|
||||
merr = multierror.Append(merr, fmt.Errorf("%s: %w", root, err))
|
||||
@@ -496,7 +512,7 @@ func (r *registryConfigurator) deleteInterfaceRegistryKeyProperty(propertyKey st
|
||||
}
|
||||
|
||||
func (r *registryConfigurator) getInterfaceRegistryKey() (registry.Key, error) {
|
||||
regKeyPath := interfaceConfigPath + "\\" + r.guid
|
||||
regKeyPath := InterfaceConfigPath + "\\" + r.guid
|
||||
regKey, err := registry.OpenKey(registry.LOCAL_MACHINE, regKeyPath, registry.SET_VALUE)
|
||||
if err != nil {
|
||||
return regKey, fmt.Errorf("open HKEY_LOCAL_MACHINE\\%s: %w", regKeyPath, err)
|
||||
|
||||
15
client/internal/dns/interface_index.go
Normal file
15
client/internal/dns/interface_index.go
Normal file
@@ -0,0 +1,15 @@
|
||||
package dns
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
)
|
||||
|
||||
func getInterfaceIndex(interfaceName string) (int, error) {
|
||||
iface, err := net.InterfaceByName(interfaceName)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("lookup interface %q: %w", interfaceName, err)
|
||||
}
|
||||
|
||||
return iface.Index, nil
|
||||
}
|
||||
35
client/internal/dns/interface_index_test.go
Normal file
35
client/internal/dns/interface_index_test.go
Normal file
@@ -0,0 +1,35 @@
|
||||
package dns
|
||||
|
||||
import (
|
||||
"net"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestGetInterfaceIndexExisting(t *testing.T) {
|
||||
interfaces, err := net.Interfaces()
|
||||
if err != nil {
|
||||
t.Fatalf("list network interfaces: %v", err)
|
||||
}
|
||||
if len(interfaces) == 0 {
|
||||
t.Fatal("expected at least one network interface")
|
||||
}
|
||||
|
||||
iface := interfaces[0]
|
||||
index, err := getInterfaceIndex(iface.Name)
|
||||
if err != nil {
|
||||
t.Fatalf("look up existing interface %q: %v", iface.Name, err)
|
||||
}
|
||||
if index != iface.Index {
|
||||
t.Fatalf("expected interface index %d, got %d", iface.Index, index)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetInterfaceIndexMissing(t *testing.T) {
|
||||
index, err := getInterfaceIndex("netbird-interface-that-does-not-exist")
|
||||
if index != 0 {
|
||||
t.Fatalf("expected missing interface index to be 0, got %d", index)
|
||||
}
|
||||
if err == nil {
|
||||
t.Fatal("expected missing interface lookup to return an error")
|
||||
}
|
||||
}
|
||||
@@ -51,7 +51,5 @@ func (n *notifier) notify() {
|
||||
return
|
||||
}
|
||||
|
||||
go func(l listener.NetworkChangeListener) {
|
||||
l.OnNetworkChanged("")
|
||||
}(n.listener)
|
||||
n.listener.OnNetworkChanged("")
|
||||
}
|
||||
|
||||
@@ -252,7 +252,7 @@ func NewDefaultServerPermanentUpstream(
|
||||
ds.hostsDNSHolder.set(hostsDnsList)
|
||||
ds.permanent = true
|
||||
ds.currentConfig = dnsConfigToHostDNSConfig(config, ds.service.RuntimeIP(), ds.service.RuntimePort())
|
||||
ds.searchDomainNotifier = newNotifier(ds.SearchDomains())
|
||||
ds.searchDomainNotifier = newNotifier(ds.searchDomains())
|
||||
ds.searchDomainNotifier.setListener(listener)
|
||||
setServerDns(ds)
|
||||
return ds
|
||||
@@ -602,6 +602,12 @@ func (s *DefaultServer) UpdateDNSServer(serial uint64, update nbdns.Config) erro
|
||||
}
|
||||
|
||||
func (s *DefaultServer) SearchDomains() []string {
|
||||
s.mux.Lock()
|
||||
defer s.mux.Unlock()
|
||||
return s.searchDomains()
|
||||
}
|
||||
|
||||
func (s *DefaultServer) searchDomains() []string {
|
||||
var searchDomains []string
|
||||
|
||||
for _, dConf := range s.currentConfig.Domains {
|
||||
@@ -686,7 +692,7 @@ func (s *DefaultServer) applyConfiguration(update nbdns.Config) error {
|
||||
}()
|
||||
|
||||
if s.searchDomainNotifier != nil {
|
||||
s.searchDomainNotifier.onNewSearchDomains(s.SearchDomains())
|
||||
s.searchDomainNotifier.onNewSearchDomains(s.searchDomains())
|
||||
}
|
||||
|
||||
s.updateNSGroupStates(update.NameServerGroups)
|
||||
|
||||
@@ -130,8 +130,3 @@ func GetClientPrivate(iface privateClientIface, upstreamIP netip.Addr, dialTimeo
|
||||
}
|
||||
return client, nil
|
||||
}
|
||||
|
||||
func getInterfaceIndex(interfaceName string) (int, error) {
|
||||
iface, err := net.InterfaceByName(interfaceName)
|
||||
return iface.Index, err
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ import (
|
||||
"golang.zx2c4.com/wireguard/tun/netstack"
|
||||
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
|
||||
|
||||
"github.com/netbirdio/netbird/client/anonymize"
|
||||
nberrors "github.com/netbirdio/netbird/client/errors"
|
||||
"github.com/netbirdio/netbird/client/firewall"
|
||||
"github.com/netbirdio/netbird/client/firewall/firewalld"
|
||||
@@ -39,6 +40,7 @@ import (
|
||||
dnsconfig "github.com/netbirdio/netbird/client/internal/dns/config"
|
||||
"github.com/netbirdio/netbird/client/internal/dnsfwd"
|
||||
"github.com/netbirdio/netbird/client/internal/expose"
|
||||
"github.com/netbirdio/netbird/client/internal/filedrop"
|
||||
"github.com/netbirdio/netbird/client/internal/ingressgw"
|
||||
"github.com/netbirdio/netbird/client/internal/lazyconn"
|
||||
"github.com/netbirdio/netbird/client/internal/metrics"
|
||||
@@ -58,6 +60,7 @@ import (
|
||||
"github.com/netbirdio/netbird/client/internal/syncstore"
|
||||
"github.com/netbirdio/netbird/client/internal/updater"
|
||||
"github.com/netbirdio/netbird/client/jobexec"
|
||||
"github.com/netbirdio/netbird/client/netstate"
|
||||
cProto "github.com/netbirdio/netbird/client/proto"
|
||||
"github.com/netbirdio/netbird/client/system"
|
||||
nbdns "github.com/netbirdio/netbird/dns"
|
||||
@@ -180,6 +183,10 @@ type EngineServices struct {
|
||||
UpdateManager *updater.Manager
|
||||
ClientMetrics *metrics.ClientMetrics
|
||||
MetricsCtx context.Context
|
||||
FileDrop *filedrop.Manager
|
||||
// NetState gates the reconnection loops on OS-reported network
|
||||
// availability; nil disables gating.
|
||||
NetState *netstate.State
|
||||
}
|
||||
|
||||
// Engine is a mechanism responsible for reacting on Signal and Management stream events and managing connections to the remote peers.
|
||||
@@ -203,6 +210,10 @@ type Engine struct {
|
||||
config *EngineConfig
|
||||
mobileDep MobileDependency
|
||||
|
||||
// netState gates the peer reconnection guards on OS-reported network
|
||||
// availability; nil disables gating.
|
||||
netState *netstate.State
|
||||
|
||||
// STUNs is a list of STUN servers used by ICE
|
||||
STUNs []*stun.URI
|
||||
// TURNs is a list of STUN servers used by ICE
|
||||
@@ -235,6 +246,10 @@ type Engine struct {
|
||||
|
||||
sshServer sshServer
|
||||
|
||||
fileDrop *filedrop.Manager
|
||||
fileDropRunning bool
|
||||
fileDropPort uint16
|
||||
|
||||
statusRecorder *peer.Status
|
||||
|
||||
firewall firewallManager.Manager
|
||||
@@ -336,6 +351,7 @@ func NewEngine(
|
||||
syncMsgMux: &sync.Mutex{},
|
||||
config: config,
|
||||
mobileDep: mobileDep,
|
||||
netState: services.NetState,
|
||||
STUNs: []*stun.URI{},
|
||||
TURNs: []*stun.URI{},
|
||||
networkSerial: 0,
|
||||
@@ -349,6 +365,7 @@ func NewEngine(
|
||||
metricsCtx: services.MetricsCtx,
|
||||
updateManager: services.UpdateManager,
|
||||
syncStoreDir: config.StateDir,
|
||||
fileDrop: services.FileDrop,
|
||||
}
|
||||
// sessionWatcher keeps the SubscribeStatus consumers in sync with the
|
||||
// session expiry deadline. Deadline-change ticks come for free via
|
||||
@@ -414,6 +431,8 @@ func (e *Engine) stopLocked() {
|
||||
log.Warnf("failed to stop SSH server: %v", err)
|
||||
}
|
||||
|
||||
e.stopFileDrop()
|
||||
|
||||
e.cleanupSSHConfig()
|
||||
|
||||
if e.ingressGatewayMgr != nil {
|
||||
@@ -572,12 +591,7 @@ func (e *Engine) Start(netbirdConfig *mgmProto.NetbirdConfig, mgmtURL *url.URL)
|
||||
}
|
||||
e.stateManager.Start()
|
||||
|
||||
initialRoutes, dnsConfig, dnsFeatureFlag, err := e.readInitialSettings()
|
||||
if err != nil {
|
||||
return fmt.Errorf("read initial settings: %w", err)
|
||||
}
|
||||
|
||||
dnsServer, err := e.newDnsServer(dnsConfig)
|
||||
dnsServer, err := e.newDnsServer()
|
||||
if err != nil {
|
||||
return fmt.Errorf("create dns server: %w", err)
|
||||
}
|
||||
@@ -595,10 +609,8 @@ func (e *Engine) Start(netbirdConfig *mgmProto.NetbirdConfig, mgmtURL *url.URL)
|
||||
WGInterface: e.wgInterface,
|
||||
StatusRecorder: e.statusRecorder,
|
||||
RelayManager: e.relayManager,
|
||||
InitialRoutes: initialRoutes,
|
||||
StateManager: e.stateManager,
|
||||
DNSServer: dnsServer,
|
||||
DNSFeatureFlag: dnsFeatureFlag,
|
||||
PeerStore: e.peerStore,
|
||||
DisableClientRoutes: e.config.DisableClientRoutes,
|
||||
DisableServerRoutes: e.config.DisableServerRoutes,
|
||||
@@ -1299,6 +1311,8 @@ func (e *Engine) updateConfig(conf *mgmProto.PeerConfig) error {
|
||||
}
|
||||
}
|
||||
|
||||
e.startFileDrop()
|
||||
|
||||
state := e.statusRecorder.GetLocalPeerState()
|
||||
state.IP = e.wgInterface.Address().String()
|
||||
state.IPv6 = e.wgInterface.Address().IPv6String()
|
||||
@@ -1392,6 +1406,7 @@ func (e *Engine) handleBundle(params *mgmProto.BundleParameters) (*mgmProto.JobR
|
||||
|
||||
bundleJobParams := debug.BundleConfig{
|
||||
Anonymize: params.Anonymize,
|
||||
AnonymizeLevel: anonymize.ParseLevel(params.AnonymizeLevel),
|
||||
IncludeSystemInfo: true,
|
||||
LogFileCount: uint32(params.LogFileCount),
|
||||
}
|
||||
@@ -1898,7 +1913,8 @@ func (e *Engine) createPeerConn(pubKey string, allowedIPs []netip.Prefix, agentV
|
||||
Addr: e.getRosenpassAddr(),
|
||||
PermissiveMode: e.config.RosenpassPermissive,
|
||||
},
|
||||
ICEConfig: e.createICEConfig(),
|
||||
ICEConfig: e.createICEConfig(),
|
||||
NetworkState: e.netState,
|
||||
}
|
||||
|
||||
serviceDependencies := peer.ServiceDependencies{
|
||||
@@ -1965,6 +1981,8 @@ func (e *Engine) receiveSignalEvents() error {
|
||||
return err
|
||||
}
|
||||
|
||||
e.recordFiledropPort(msg.Key, msg.GetBody().GetFiledropPort())
|
||||
|
||||
log.Debugf("receiveMSG: took %s to get lock for peer %s with session id %s", gotLock, msg.Key, offerAnswer.SessionID)
|
||||
|
||||
if msg.Body.Type == sProto.Body_OFFER {
|
||||
@@ -2102,42 +2120,6 @@ func (e *Engine) close() {
|
||||
}
|
||||
}
|
||||
|
||||
func (e *Engine) readInitialSettings() ([]*route.Route, *nbdns.Config, bool, error) {
|
||||
if runtime.GOOS != "android" {
|
||||
// nolint:nilnil
|
||||
return nil, nil, false, nil
|
||||
}
|
||||
|
||||
info := system.GetInfo(e.ctx)
|
||||
info.SetFlags(
|
||||
e.config.RosenpassEnabled,
|
||||
e.config.RosenpassPermissive,
|
||||
&e.config.ServerSSHAllowed,
|
||||
e.config.DisableClientRoutes,
|
||||
e.config.DisableServerRoutes,
|
||||
e.config.DisableDNS,
|
||||
e.config.DisableFirewall,
|
||||
e.config.BlockLANAccess,
|
||||
e.config.BlockInbound,
|
||||
e.config.DisableIPv6,
|
||||
e.config.SyncMessageVersion,
|
||||
e.config.EnableSSHRoot,
|
||||
e.config.EnableSSHSFTP,
|
||||
e.config.EnableSSHLocalPortForwarding,
|
||||
e.config.EnableSSHRemotePortForwarding,
|
||||
e.config.DisableSSHAuth,
|
||||
)
|
||||
|
||||
netMap, err := e.mgmClient.GetNetworkMap(info)
|
||||
if err != nil {
|
||||
return nil, nil, false, err
|
||||
}
|
||||
routes := toRoutes(netMap.GetRoutes())
|
||||
dnsCfg := toDNSConfig(netMap.GetDNSConfig(), e.wgInterface.Address())
|
||||
dnsFeatureFlag := toDNSFeatureFlag(netMap)
|
||||
return routes, &dnsCfg, dnsFeatureFlag, nil
|
||||
}
|
||||
|
||||
func (e *Engine) newWgIface() (*iface.WGIface, error) {
|
||||
transportNet, err := e.newStdNet()
|
||||
if err != nil {
|
||||
@@ -2172,7 +2154,7 @@ func (e *Engine) newWgIface() (*iface.WGIface, error) {
|
||||
func (e *Engine) wgInterfaceCreate() (err error) {
|
||||
switch runtime.GOOS {
|
||||
case "android":
|
||||
err = e.wgInterface.CreateOnAndroid(e.routeManager.InitialRouteRange(), e.dnsServer.DnsIP().String(), e.dnsServer.SearchDomains())
|
||||
err = e.wgInterface.CreateOnAndroid(e.routeManager.CurrentRouteRange(), e.dnsServer.DnsIP().String(), e.dnsServer.SearchDomains())
|
||||
case "ios":
|
||||
e.mobileDep.NetworkChangeListener.SetInterfaceIP(e.config.WgAddr.String())
|
||||
if e.config.WgAddr.HasIPv6() {
|
||||
@@ -2185,7 +2167,7 @@ func (e *Engine) wgInterfaceCreate() (err error) {
|
||||
return err
|
||||
}
|
||||
|
||||
func (e *Engine) newDnsServer(dnsConfig *nbdns.Config) (dns.Server, error) {
|
||||
func (e *Engine) newDnsServer() (dns.Server, error) {
|
||||
// due to tests where we are using a mocked version of the DNS server
|
||||
if e.dnsServer != nil {
|
||||
return e.dnsServer, nil
|
||||
@@ -2197,7 +2179,7 @@ func (e *Engine) newDnsServer(dnsConfig *nbdns.Config) (dns.Server, error) {
|
||||
e.ctx,
|
||||
e.wgInterface,
|
||||
e.mobileDep.HostDNSAddresses,
|
||||
*dnsConfig,
|
||||
nbdns.Config{},
|
||||
e.mobileDep.NetworkChangeListener,
|
||||
e.statusRecorder,
|
||||
e.config.DisableDNS,
|
||||
@@ -2480,6 +2462,8 @@ func (e *Engine) GetWgV6Addr() netip.Addr {
|
||||
return e.wgInterface.Address().IPv6
|
||||
}
|
||||
|
||||
// RenewTun swaps the tunnel device for the one behind fd, which the platform
|
||||
// hands over whenever it re-establishes the interface.
|
||||
func (e *Engine) RenewTun(fd int) error {
|
||||
e.syncMsgMux.Lock()
|
||||
wgInterface := e.wgInterface
|
||||
@@ -2489,7 +2473,12 @@ func (e *Engine) RenewTun(fd int) error {
|
||||
return fmt.Errorf("wireguard interface not initialized")
|
||||
}
|
||||
|
||||
return wgInterface.RenewTun(fd)
|
||||
if err := wgInterface.RenewTun(fd); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
e.restartFileDrop()
|
||||
return nil
|
||||
}
|
||||
|
||||
// updateDNSForwarder start or stop the DNS forwarder based on the domains and the feature flag
|
||||
|
||||
143
client/internal/engine_filedrop.go
Normal file
143
client/internal/engine_filedrop.go
Normal file
@@ -0,0 +1,143 @@
|
||||
package internal
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"net/netip"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/filedrop"
|
||||
nftypes "github.com/netbirdio/netbird/client/internal/netflow/types"
|
||||
"github.com/netbirdio/netbird/client/internal/peer"
|
||||
)
|
||||
|
||||
type filedropResolver struct {
|
||||
status *peer.Status
|
||||
}
|
||||
|
||||
// ResolvePeer implements filedrop.PeerResolver.
|
||||
func (r filedropResolver) ResolvePeer(addr netip.Addr) (filedrop.PeerKey, string, bool) {
|
||||
state, ok := r.status.PeerStateByIP(addr.String())
|
||||
if !ok {
|
||||
return "", "", false
|
||||
}
|
||||
return filedrop.PeerKey(state.PubKey), state.FQDN, true
|
||||
}
|
||||
|
||||
func (e *Engine) startFileDrop() {
|
||||
if e.fileDrop == nil || e.fileDropRunning || e.wgInterface == nil {
|
||||
return
|
||||
}
|
||||
if e.config.BlockInbound {
|
||||
log.Info("file drop receiver is disabled because inbound connections are blocked")
|
||||
e.setFileDropTunnel()
|
||||
return
|
||||
}
|
||||
|
||||
wgAddr := e.wgInterface.Address()
|
||||
addr := netip.AddrPortFrom(wgAddr.IP, filedrop.Port)
|
||||
resolver := filedropResolver{status: e.statusRecorder}
|
||||
|
||||
netstackNet := e.wgInterface.GetNet()
|
||||
if err := e.fileDrop.StartReceiver(e.ctx, addr, netstackNet, resolver); err != nil {
|
||||
log.Errorf("failed to start file drop receiver: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
bound := e.fileDrop.ReceiverPort()
|
||||
if bound == 0 {
|
||||
bound = filedrop.Port
|
||||
}
|
||||
e.fileDropPort = bound
|
||||
|
||||
if v6 := wgAddr.IPv6; v6.IsValid() {
|
||||
if err := e.fileDrop.AddReceiverListener(e.ctx, netip.AddrPortFrom(v6, bound)); err != nil {
|
||||
log.Warnf("failed to add IPv6 file drop listener: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
if netstackNet != nil {
|
||||
if registrar, ok := e.firewall.(interface {
|
||||
RegisterNetstackService(protocol nftypes.Protocol, port uint16)
|
||||
}); ok {
|
||||
registrar.RegisterNetstackService(nftypes.TCP, bound)
|
||||
}
|
||||
}
|
||||
|
||||
if bound != filedrop.Port {
|
||||
e.signaler.SetFiledropPort(bound)
|
||||
}
|
||||
|
||||
e.setFileDropTunnel()
|
||||
e.fileDropRunning = true
|
||||
}
|
||||
|
||||
// recordFiledropPort stores the file drop port a peer advertised over signaling;
|
||||
// a value that does not fit a port is treated as the default.
|
||||
func (e *Engine) recordFiledropPort(peerKey string, port uint32) {
|
||||
if e.fileDrop == nil {
|
||||
return
|
||||
}
|
||||
if port > 65535 {
|
||||
port = 0
|
||||
}
|
||||
e.fileDrop.Ports().Set(filedrop.PeerKey(peerKey), uint16(port))
|
||||
}
|
||||
|
||||
func (e *Engine) setFileDropTunnel() {
|
||||
var dial filedrop.DialFunc
|
||||
if netstackNet := e.wgInterface.GetNet(); netstackNet != nil {
|
||||
dial = func(ctx context.Context, _, addr string) (net.Conn, error) {
|
||||
addrPort, err := netip.ParseAddrPort(addr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return netstackNet.DialContextTCPAddrPort(ctx, addrPort)
|
||||
}
|
||||
} else {
|
||||
dialer := &net.Dialer{}
|
||||
dial = dialer.DialContext
|
||||
}
|
||||
|
||||
e.fileDrop.SetTunnel(dial, e.statusRecorder.GetLocalPeerState().FQDN)
|
||||
}
|
||||
|
||||
// restartFileDrop rebinds the receiver after the platform replaced the tunnel
|
||||
// device. The listeners are bound to the overlay address of the interface being
|
||||
// swapped out and do not survive it: Android renews the tun on every route
|
||||
// change, which leaves the IPv4 listener dead with accept4: invalid argument.
|
||||
func (e *Engine) restartFileDrop() {
|
||||
e.syncMsgMux.Lock()
|
||||
defer e.syncMsgMux.Unlock()
|
||||
|
||||
if e.fileDrop == nil || !e.fileDropRunning || e.wgInterface == nil {
|
||||
return
|
||||
}
|
||||
|
||||
e.stopFileDrop()
|
||||
e.startFileDrop()
|
||||
}
|
||||
|
||||
func (e *Engine) stopFileDrop() {
|
||||
if e.fileDrop == nil {
|
||||
return
|
||||
}
|
||||
|
||||
if e.fileDropRunning {
|
||||
if netstackNet := e.wgInterface.GetNet(); netstackNet != nil {
|
||||
if registrar, ok := e.firewall.(interface {
|
||||
UnregisterNetstackService(protocol nftypes.Protocol, port uint16)
|
||||
}); ok {
|
||||
registrar.UnregisterNetstackService(nftypes.TCP, e.fileDropPort)
|
||||
}
|
||||
}
|
||||
e.signaler.SetFiledropPort(0)
|
||||
}
|
||||
|
||||
if err := e.fileDrop.StopReceiver(); err != nil {
|
||||
log.Warnf("failed to stop file drop receiver: %v", err)
|
||||
}
|
||||
e.fileDropRunning = false
|
||||
e.fileDropPort = 0
|
||||
}
|
||||
20
client/internal/engine_tunsettings.go
Normal file
20
client/internal/engine_tunsettings.go
Normal file
@@ -0,0 +1,20 @@
|
||||
package internal
|
||||
|
||||
func (e *Engine) TunSettings() ([]string, []string) {
|
||||
e.syncMsgMux.Lock()
|
||||
routeManager := e.routeManager
|
||||
dnsServer := e.dnsServer
|
||||
e.syncMsgMux.Unlock()
|
||||
|
||||
var routes []string
|
||||
if routeManager != nil {
|
||||
routes = routeManager.CurrentRouteRange()
|
||||
}
|
||||
|
||||
var searchDomains []string
|
||||
if dnsServer != nil {
|
||||
searchDomains = dnsServer.SearchDomains()
|
||||
}
|
||||
|
||||
return routes, searchDomains
|
||||
}
|
||||
446
client/internal/filedrop/client.go
Normal file
446
client/internal/filedrop/client.go
Normal file
@@ -0,0 +1,446 @@
|
||||
package filedrop
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultPollTimeout = 60 * time.Second
|
||||
defaultOfferTimeout = DefaultOfferTTL
|
||||
uploadRetryDelay = 2 * time.Second
|
||||
maxUploadAttempts = 3
|
||||
)
|
||||
|
||||
// DialFunc opens a connection to the receiving peer over the tunnel.
|
||||
type DialFunc func(ctx context.Context, network, addr string) (net.Conn, error)
|
||||
|
||||
// Payload is one item to send; Open is called per attempt starting at an offset.
|
||||
type Payload struct {
|
||||
Meta FileMeta
|
||||
Open func(offset int64) (io.ReadCloser, error)
|
||||
}
|
||||
|
||||
// ProgressFunc reports staged bytes for one item as the upload streams.
|
||||
type ProgressFunc func(index int, sent int64, total int64)
|
||||
|
||||
// ClientConfig configures the sending side.
|
||||
type ClientConfig struct {
|
||||
Dial DialFunc
|
||||
SenderName string
|
||||
PollTimeout time.Duration
|
||||
OfferTimeout time.Duration
|
||||
}
|
||||
|
||||
type progressReader struct {
|
||||
r io.Reader
|
||||
sent int64
|
||||
total int64
|
||||
report func(sent int64)
|
||||
}
|
||||
|
||||
// Client sends offers and payloads to a peer's file drop service.
|
||||
type Client struct {
|
||||
http *http.Client
|
||||
senderName string
|
||||
pollTimeout time.Duration
|
||||
offerTimeout time.Duration
|
||||
}
|
||||
|
||||
func (p *progressReader) Read(b []byte) (int, error) {
|
||||
n, err := p.r.Read(b)
|
||||
if n > 0 {
|
||||
p.sent += int64(n)
|
||||
p.report(p.sent)
|
||||
}
|
||||
return n, err
|
||||
}
|
||||
|
||||
// NewClient builds a sending client over the given dialer.
|
||||
func NewClient(cfg ClientConfig) (*Client, error) {
|
||||
if cfg.Dial == nil {
|
||||
return nil, errors.New("dial function is required")
|
||||
}
|
||||
|
||||
pollTimeout := cfg.PollTimeout
|
||||
if pollTimeout <= 0 {
|
||||
pollTimeout = defaultPollTimeout
|
||||
}
|
||||
offerTimeout := cfg.OfferTimeout
|
||||
if offerTimeout <= 0 {
|
||||
offerTimeout = defaultOfferTimeout
|
||||
}
|
||||
|
||||
transport := &http.Transport{
|
||||
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) { return cfg.Dial(ctx, network, addr) },
|
||||
MaxIdleConnsPerHost: 2,
|
||||
ResponseHeaderTimeout: pollTimeout + 30*time.Second,
|
||||
}
|
||||
|
||||
return &Client{
|
||||
http: &http.Client{Transport: transport},
|
||||
senderName: cfg.SenderName,
|
||||
pollTimeout: pollTimeout,
|
||||
offerTimeout: offerTimeout,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// TextPayload builds an inline text payload, which is carried in the offer itself.
|
||||
func TextPayload(name, text string) Payload {
|
||||
return Payload{
|
||||
Meta: FileMeta{
|
||||
Name: name,
|
||||
Size: int64(len(text)),
|
||||
ContentType: "text/plain",
|
||||
Kind: KindText,
|
||||
Text: text,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Send offers the payloads to the peer at addr and uploads them once accepted.
|
||||
func (c *Client) Send(ctx context.Context, addr netip.AddrPort, payloads []Payload, progress ProgressFunc) (OfferID, error) {
|
||||
id, decision, err := c.Offer(ctx, addr, payloads)
|
||||
if err != nil {
|
||||
return id, err
|
||||
}
|
||||
|
||||
decision, err = c.AwaitDecision(ctx, addr, id, decision)
|
||||
if err != nil {
|
||||
return id, err
|
||||
}
|
||||
if err := decisionError(decision); err != nil {
|
||||
return id, err
|
||||
}
|
||||
|
||||
return id, c.Upload(ctx, addr, id, payloads, progress)
|
||||
}
|
||||
|
||||
// Offer announces the payloads and returns the offer ID with its initial decision.
|
||||
func (c *Client) Offer(ctx context.Context, addr netip.AddrPort, payloads []Payload) (OfferID, Decision, error) {
|
||||
if len(payloads) == 0 {
|
||||
return "", DecisionPending, fmt.Errorf("%w: no payloads", ErrInvalidOffer)
|
||||
}
|
||||
return c.postOffer(ctx, baseURL(addr), payloads)
|
||||
}
|
||||
|
||||
// AwaitDecision resolves a pending decision by long-polling the receiver.
|
||||
func (c *Client) AwaitDecision(ctx context.Context, addr netip.AddrPort, id OfferID, decision Decision) (Decision, error) {
|
||||
if decision != DecisionPending {
|
||||
return decision, nil
|
||||
}
|
||||
return c.awaitDecision(ctx, baseURL(addr), id)
|
||||
}
|
||||
|
||||
// Upload streams every non-inline payload of an accepted offer.
|
||||
func (c *Client) Upload(ctx context.Context, addr netip.AddrPort, id OfferID, payloads []Payload, progress ProgressFunc) error {
|
||||
base := baseURL(addr)
|
||||
for i, p := range payloads {
|
||||
if p.Meta.Kind == KindText {
|
||||
continue
|
||||
}
|
||||
if err := c.uploadFile(ctx, base, id, i, p, progress); err != nil {
|
||||
return fmt.Errorf("upload %s: %w", p.Meta.Name, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Cancel withdraws an offer, taking the receiver's consent prompt with it.
|
||||
func (c *Client) Cancel(ctx context.Context, addr netip.AddrPort, id OfferID) error {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodDelete, offerURL(baseURL(addr), id), nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("build cancel request: %w", err)
|
||||
}
|
||||
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("send cancel: %w", err)
|
||||
}
|
||||
defer drainAndClose(resp)
|
||||
|
||||
if resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusNotFound {
|
||||
return statusError(resp)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) postOffer(ctx context.Context, base string, payloads []Payload) (OfferID, Decision, error) {
|
||||
files := make([]FileMeta, len(payloads))
|
||||
for i, p := range payloads {
|
||||
files[i] = p.Meta
|
||||
}
|
||||
|
||||
body, err := json.Marshal(OfferRequest{SenderName: c.senderName, Files: files})
|
||||
if err != nil {
|
||||
return "", DecisionPending, fmt.Errorf("encode offer: %w", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, base+pathOffers, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return "", DecisionPending, fmt.Errorf("build offer request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return "", DecisionPending, fmt.Errorf("send offer: %w", err)
|
||||
}
|
||||
defer drainAndClose(resp)
|
||||
|
||||
switch resp.StatusCode {
|
||||
case http.StatusCreated, http.StatusAccepted:
|
||||
case http.StatusForbidden:
|
||||
return "", DecisionPending, ErrRefused
|
||||
default:
|
||||
return "", DecisionPending, statusError(resp)
|
||||
}
|
||||
|
||||
var offer OfferResponse
|
||||
if err := json.NewDecoder(io.LimitReader(resp.Body, maxOfferBodySize)).Decode(&offer); err != nil {
|
||||
return "", DecisionPending, fmt.Errorf("decode offer response: %w", err)
|
||||
}
|
||||
if offer.ID == "" {
|
||||
return "", DecisionPending, fmt.Errorf("%w: receiver returned no offer id", ErrInvalidOffer)
|
||||
}
|
||||
if !offer.Decision.valid() {
|
||||
return "", DecisionPending, fmt.Errorf("%w: receiver returned decision %s", ErrInvalidOffer, offer.Decision)
|
||||
}
|
||||
|
||||
return offer.ID, offer.Decision, nil
|
||||
}
|
||||
|
||||
func (c *Client) awaitDecision(ctx context.Context, base string, id OfferID) (Decision, error) {
|
||||
deadline := time.Now().Add(c.offerTimeout)
|
||||
|
||||
for time.Now().Before(deadline) {
|
||||
decision, err := c.pollDecision(ctx, base, id)
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return DecisionPending, ctx.Err()
|
||||
}
|
||||
log.Debugf("poll file drop decision: %v", err)
|
||||
if !sleepCtx(ctx, uploadRetryDelay) {
|
||||
return DecisionPending, ctx.Err()
|
||||
}
|
||||
continue
|
||||
}
|
||||
if decision != DecisionPending {
|
||||
return decision, nil
|
||||
}
|
||||
}
|
||||
|
||||
return DecisionExpired, nil
|
||||
}
|
||||
|
||||
func (c *Client) pollDecision(ctx context.Context, base string, id OfferID) (Decision, error) {
|
||||
pollCtx, cancel := context.WithTimeout(ctx, c.pollTimeout)
|
||||
defer cancel()
|
||||
|
||||
req, err := http.NewRequestWithContext(pollCtx, http.MethodGet, offerURL(base, id), nil)
|
||||
if err != nil {
|
||||
return DecisionPending, fmt.Errorf("build status request: %w", err)
|
||||
}
|
||||
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return DecisionPending, fmt.Errorf("poll status: %w", err)
|
||||
}
|
||||
defer drainAndClose(resp)
|
||||
|
||||
if resp.StatusCode == http.StatusNotFound {
|
||||
return DecisionPending, ErrOfferNotFound
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return DecisionPending, statusError(resp)
|
||||
}
|
||||
|
||||
var offer OfferResponse
|
||||
if err := json.NewDecoder(io.LimitReader(resp.Body, maxOfferBodySize)).Decode(&offer); err != nil {
|
||||
return DecisionPending, fmt.Errorf("decode status response: %w", err)
|
||||
}
|
||||
if !offer.Decision.valid() {
|
||||
return DecisionPending, fmt.Errorf("%w: receiver returned decision %s", ErrInvalidOffer, offer.Decision)
|
||||
}
|
||||
return offer.Decision, nil
|
||||
}
|
||||
|
||||
func (c *Client) uploadFile(ctx context.Context, base string, id OfferID, index int, p Payload, progress ProgressFunc) error {
|
||||
var lastErr error
|
||||
|
||||
for attempt := range maxUploadAttempts {
|
||||
offset := int64(0)
|
||||
if attempt > 0 {
|
||||
if !sleepCtx(ctx, uploadRetryDelay) {
|
||||
return ctx.Err()
|
||||
}
|
||||
confirmed, err := c.confirmedOffset(ctx, base, id, index)
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
continue
|
||||
}
|
||||
offset = confirmed
|
||||
}
|
||||
|
||||
if offset >= p.Meta.Size {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := c.putFile(ctx, base, id, index, p, offset, progress); err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
lastErr = err
|
||||
log.Debugf("upload attempt %d for %s: %v", attempt+1, p.Meta.Name, err)
|
||||
continue
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
return lastErr
|
||||
}
|
||||
|
||||
func (c *Client) putFile(ctx context.Context, base string, id OfferID, index int, p Payload, offset int64, progress ProgressFunc) error {
|
||||
if p.Open == nil {
|
||||
return fmt.Errorf("payload %s has no reader", p.Meta.Name)
|
||||
}
|
||||
|
||||
body, err := p.Open(offset)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open payload: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
if err := body.Close(); err != nil {
|
||||
log.Debugf("close payload reader: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
var reader io.Reader = body
|
||||
if progress != nil {
|
||||
reader = &progressReader{
|
||||
r: body,
|
||||
sent: offset,
|
||||
total: p.Meta.Size,
|
||||
report: func(sent int64) {
|
||||
progress(index, sent, p.Meta.Size)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
url := fileURL(base, id, index) + "?offset=" + strconv.FormatInt(offset, 10)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPut, url, reader)
|
||||
if err != nil {
|
||||
return fmt.Errorf("build upload request: %w", err)
|
||||
}
|
||||
req.ContentLength = p.Meta.Size - offset
|
||||
req.Header.Set("Content-Type", contentTypeOrDefault(p.Meta.ContentType))
|
||||
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("send payload: %w", err)
|
||||
}
|
||||
defer drainAndClose(resp)
|
||||
|
||||
if resp.StatusCode == http.StatusForbidden {
|
||||
return ErrNotAccepted
|
||||
}
|
||||
if resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusOK {
|
||||
return statusError(resp)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) confirmedOffset(ctx context.Context, base string, id OfferID, index int) (int64, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodHead, fileURL(base, id, index), nil)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("build probe request: %w", err)
|
||||
}
|
||||
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("probe upload: %w", err)
|
||||
}
|
||||
defer drainAndClose(resp)
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return 0, statusError(resp)
|
||||
}
|
||||
|
||||
raw := resp.Header.Get(HeaderReceivedBytes)
|
||||
if raw == "" {
|
||||
return 0, nil
|
||||
}
|
||||
offset, err := strconv.ParseInt(raw, 10, 64)
|
||||
if err != nil || offset < 0 {
|
||||
return 0, fmt.Errorf("invalid %s header %q", HeaderReceivedBytes, raw)
|
||||
}
|
||||
return offset, nil
|
||||
}
|
||||
|
||||
func baseURL(addr netip.AddrPort) string {
|
||||
return "http://" + net.JoinHostPort(addr.Addr().Unmap().String(), strconv.Itoa(int(addr.Port())))
|
||||
}
|
||||
|
||||
func offerURL(base string, id OfferID) string {
|
||||
return base + pathOffersSlash + string(id)
|
||||
}
|
||||
|
||||
func fileURL(base string, id OfferID, index int) string {
|
||||
return offerURL(base, id) + "/" + segmentFiles + "/" + strconv.Itoa(index)
|
||||
}
|
||||
|
||||
func contentTypeOrDefault(ct string) string {
|
||||
if ct == "" {
|
||||
return "application/octet-stream"
|
||||
}
|
||||
return ct
|
||||
}
|
||||
|
||||
func statusError(resp *http.Response) error {
|
||||
return fmt.Errorf("receiver returned %s", resp.Status)
|
||||
}
|
||||
|
||||
func drainAndClose(resp *http.Response) {
|
||||
if _, err := io.Copy(io.Discard, io.LimitReader(resp.Body, maxOfferBodySize)); err != nil {
|
||||
log.Tracef("drain response body: %v", err)
|
||||
}
|
||||
if err := resp.Body.Close(); err != nil {
|
||||
log.Debugf("close response body: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func sleepCtx(ctx context.Context, d time.Duration) bool {
|
||||
timer := time.NewTimer(d)
|
||||
defer timer.Stop()
|
||||
|
||||
select {
|
||||
case <-timer.C:
|
||||
return true
|
||||
case <-ctx.Done():
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func decisionError(decision Decision) error {
|
||||
switch decision {
|
||||
case DecisionAccepted:
|
||||
return nil
|
||||
case DecisionDeclined:
|
||||
return ErrDeclined
|
||||
case DecisionExpired:
|
||||
return ErrExpired
|
||||
default:
|
||||
return fmt.Errorf("unexpected decision %s", decision)
|
||||
}
|
||||
}
|
||||
100
client/internal/filedrop/delivery.go
Normal file
100
client/internal/filedrop/delivery.go
Normal file
@@ -0,0 +1,100 @@
|
||||
package filedrop
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
func deliver(spool *Spool, offer Offer, destDir string) ([]string, error) {
|
||||
if destDir == "" {
|
||||
return nil, fmt.Errorf("no destination directory configured")
|
||||
}
|
||||
if err := os.MkdirAll(destDir, 0o755); err != nil {
|
||||
return nil, fmt.Errorf("create destination dir: %w", err)
|
||||
}
|
||||
|
||||
var delivered []string
|
||||
for i, f := range offer.Files {
|
||||
if f.Kind == KindText {
|
||||
continue
|
||||
}
|
||||
|
||||
dest, err := moveToUniqueName(spool.Path(offer.ID, i), destDir, sanitizeFileName(f.Name, i))
|
||||
if err != nil {
|
||||
return delivered, fmt.Errorf("deliver %s: %w", f.Name, err)
|
||||
}
|
||||
if err := chownToDirOwner(dest, destDir); err != nil {
|
||||
log.Debugf("failed to adopt owner for %s: %v", dest, err)
|
||||
}
|
||||
delivered = append(delivered, dest)
|
||||
}
|
||||
|
||||
spool.Remove(offer.ID)
|
||||
return delivered, nil
|
||||
}
|
||||
|
||||
func sanitizeFileName(name string, index int) string {
|
||||
name = filepath.Base(filepath.Clean(strings.ReplaceAll(name, "\\", "/")))
|
||||
if name == "" || name == "." || name == ".." || name == string(filepath.Separator) {
|
||||
return fmt.Sprintf("file-%d", index)
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
func moveToUniqueName(src, dir, name string) (string, error) {
|
||||
ext := filepath.Ext(name)
|
||||
stem := strings.TrimSuffix(name, ext)
|
||||
|
||||
for attempt := 0; attempt < 1000; attempt++ {
|
||||
candidate := name
|
||||
if attempt > 0 {
|
||||
candidate = fmt.Sprintf("%s (%d)%s", stem, attempt, ext)
|
||||
}
|
||||
dest := filepath.Join(dir, candidate)
|
||||
|
||||
f, err := os.OpenFile(dest, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o644)
|
||||
if err != nil {
|
||||
if os.IsExist(err) {
|
||||
continue
|
||||
}
|
||||
return "", fmt.Errorf("create destination: %w", err)
|
||||
}
|
||||
|
||||
if err := moveInto(f, src); err != nil {
|
||||
_ = f.Close()
|
||||
_ = os.Remove(dest)
|
||||
return "", err
|
||||
}
|
||||
if err := f.Close(); err != nil {
|
||||
return "", fmt.Errorf("close destination: %w", err)
|
||||
}
|
||||
if err := os.Remove(src); err != nil {
|
||||
log.Debugf("failed to remove spooled source %s: %v", src, err)
|
||||
}
|
||||
return dest, nil
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("no free name for %s in %s", name, dir)
|
||||
}
|
||||
|
||||
func moveInto(dst *os.File, src string) error {
|
||||
s, err := os.Open(src)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open spooled file: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
if err := s.Close(); err != nil {
|
||||
log.Debugf("close spooled file: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
if _, err := io.Copy(dst, s); err != nil {
|
||||
return fmt.Errorf("copy payload: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
7
client/internal/filedrop/delivery_other.go
Normal file
7
client/internal/filedrop/delivery_other.go
Normal file
@@ -0,0 +1,7 @@
|
||||
//go:build windows || js
|
||||
|
||||
package filedrop
|
||||
|
||||
func chownToDirOwner(string, string) error {
|
||||
return nil
|
||||
}
|
||||
29
client/internal/filedrop/delivery_unix.go
Normal file
29
client/internal/filedrop/delivery_unix.go
Normal file
@@ -0,0 +1,29 @@
|
||||
//go:build !windows && !js
|
||||
|
||||
package filedrop
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
func chownToDirOwner(path, dir string) error {
|
||||
info, err := os.Stat(dir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("stat destination dir: %w", err)
|
||||
}
|
||||
|
||||
stat, ok := info.Sys().(*syscall.Stat_t)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
if os.Geteuid() != 0 || int(stat.Uid) == os.Geteuid() {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := os.Chown(path, int(stat.Uid), int(stat.Gid)); err != nil {
|
||||
return fmt.Errorf("chown delivered file: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
850
client/internal/filedrop/filedrop_test.go
Normal file
850
client/internal/filedrop/filedrop_test.go
Normal file
@@ -0,0 +1,850 @@
|
||||
package filedrop
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/netip"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
||||
)
|
||||
|
||||
const (
|
||||
testPeer = PeerKey("peer-pubkey")
|
||||
testProfile = profilemanager.ID("test-profile")
|
||||
)
|
||||
|
||||
type staticResolver struct {
|
||||
key PeerKey
|
||||
name string
|
||||
unknown bool
|
||||
}
|
||||
|
||||
func (r staticResolver) ResolvePeer(netip.Addr) (PeerKey, string, bool) {
|
||||
if r.unknown {
|
||||
return "", "", false
|
||||
}
|
||||
return r.key, r.name, true
|
||||
}
|
||||
|
||||
type recordingNotifier struct {
|
||||
mu sync.Mutex
|
||||
offers []Offer
|
||||
completed []Offer
|
||||
failed []Offer
|
||||
withdrawn []Offer
|
||||
progress int
|
||||
}
|
||||
|
||||
func (n *recordingNotifier) OnOffer(o Offer) {
|
||||
n.mu.Lock()
|
||||
defer n.mu.Unlock()
|
||||
n.offers = append(n.offers, o)
|
||||
}
|
||||
|
||||
func (n *recordingNotifier) OnProgress(Offer, int, int64) {
|
||||
n.mu.Lock()
|
||||
defer n.mu.Unlock()
|
||||
n.progress++
|
||||
}
|
||||
|
||||
func (n *recordingNotifier) OnCompleted(o Offer) {
|
||||
n.mu.Lock()
|
||||
defer n.mu.Unlock()
|
||||
n.completed = append(n.completed, o)
|
||||
}
|
||||
|
||||
func (n *recordingNotifier) OnFailed(o Offer, _ error) {
|
||||
n.mu.Lock()
|
||||
defer n.mu.Unlock()
|
||||
n.failed = append(n.failed, o)
|
||||
}
|
||||
|
||||
func (n *recordingNotifier) OnWithdrawn(o Offer) {
|
||||
n.mu.Lock()
|
||||
defer n.mu.Unlock()
|
||||
n.withdrawn = append(n.withdrawn, o)
|
||||
}
|
||||
|
||||
func (n *recordingNotifier) snapshot() (offers, completed, failed, withdrawn []Offer) {
|
||||
n.mu.Lock()
|
||||
defer n.mu.Unlock()
|
||||
return append([]Offer(nil), n.offers...), append([]Offer(nil), n.completed...),
|
||||
append([]Offer(nil), n.failed...), append([]Offer(nil), n.withdrawn...)
|
||||
}
|
||||
|
||||
func startTestServer(t *testing.T, mode Mode, resolver PeerResolver) (*Server, *Client, *recordingNotifier) {
|
||||
t.Helper()
|
||||
|
||||
policy := NewPolicyStore(testProfile)
|
||||
require.NoError(t, policy.Set(Policy{Mode: mode}))
|
||||
|
||||
notifier := &recordingNotifier{}
|
||||
srv, err := NewServer(ServerConfig{
|
||||
SpoolDir: t.TempDir(),
|
||||
Policy: policy,
|
||||
Resolver: resolver,
|
||||
Notifier: notifier,
|
||||
OfferTTL: 5 * time.Second,
|
||||
})
|
||||
require.NoError(t, err, "server setup must succeed")
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
t.Cleanup(cancel)
|
||||
|
||||
require.NoError(t, srv.Start(ctx, netip.AddrPortFrom(netip.AddrFrom4([4]byte{127, 0, 0, 1}), 0)))
|
||||
t.Cleanup(func() {
|
||||
require.NoError(t, srv.Stop())
|
||||
})
|
||||
|
||||
srv.mu.RLock()
|
||||
addr := srv.listener.Addr().String()
|
||||
srv.mu.RUnlock()
|
||||
|
||||
client, err := NewClient(ClientConfig{
|
||||
SenderName: "sender",
|
||||
PollTimeout: 2 * time.Second,
|
||||
OfferTimeout: 5 * time.Second,
|
||||
Dial: func(ctx context.Context, network, _ string) (net.Conn, error) {
|
||||
var d net.Dialer
|
||||
return d.DialContext(ctx, network, addr)
|
||||
},
|
||||
})
|
||||
require.NoError(t, err, "client setup must succeed")
|
||||
|
||||
return srv, client, notifier
|
||||
}
|
||||
|
||||
func filePayload(t *testing.T, name string, content []byte) Payload {
|
||||
t.Helper()
|
||||
|
||||
path := filepath.Join(t.TempDir(), name)
|
||||
require.NoError(t, os.WriteFile(path, content, 0o600))
|
||||
|
||||
return Payload{
|
||||
Meta: FileMeta{Name: name, Size: int64(len(content)), ContentType: "application/octet-stream"},
|
||||
Open: func(offset int64) (io.ReadCloser, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := f.Seek(offset, io.SeekStart); err != nil {
|
||||
_ = f.Close()
|
||||
return nil, err
|
||||
}
|
||||
return f, nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
var testAddr = netip.AddrPortFrom(netip.AddrFrom4([4]byte{100, 64, 0, 1}), Port)
|
||||
|
||||
func TestAutoAcceptTransfersPayload(t *testing.T) {
|
||||
srv, client, notifier := startTestServer(t, ModeAutoAccept, staticResolver{key: testPeer, name: "laptop"})
|
||||
|
||||
content := []byte(strings.Repeat("netbird", 1000))
|
||||
payload := filePayload(t, "report.bin", content)
|
||||
|
||||
var lastSent int64
|
||||
id, err := client.Send(context.Background(), testAddr, []Payload{payload}, func(_ int, sent, _ int64) {
|
||||
lastSent = sent
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, id, "receiver must return an offer id")
|
||||
|
||||
assert.Equal(t, int64(len(content)), lastSent, "progress must reach the full payload size")
|
||||
|
||||
staged, err := os.ReadFile(srv.Spool().Path(id, 0))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, content, staged, "staged bytes should match what was sent")
|
||||
|
||||
offer, ok := srv.Offers().Get(testPeer, id)
|
||||
require.True(t, ok, "offer must still be tracked")
|
||||
assert.Equal(t, StateCompleted, offer.State, "offer should be completed")
|
||||
assert.Equal(t, "laptop", offer.SenderName, "sender name should come from the resolver")
|
||||
|
||||
_, completed, _, _ := notifier.snapshot()
|
||||
require.Len(t, completed, 1, "one completion event expected")
|
||||
assert.Equal(t, id, completed[0].ID)
|
||||
}
|
||||
|
||||
func TestAskModeAcceptReleasesUpload(t *testing.T) {
|
||||
srv, client, notifier := startTestServer(t, ModeAsk, staticResolver{key: testPeer})
|
||||
|
||||
content := []byte("consent required")
|
||||
payload := filePayload(t, "note.txt", content)
|
||||
|
||||
go func() {
|
||||
for {
|
||||
offers, _, _, _ := notifier.snapshot()
|
||||
if len(offers) > 0 {
|
||||
srv.Offers().Decide(offers[0].ID, DecisionAccepted)
|
||||
return
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
}()
|
||||
|
||||
id, err := client.Send(context.Background(), testAddr, []Payload{payload}, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
staged, err := os.ReadFile(srv.Spool().Path(id, 0))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, content, staged, "payload should arrive after acceptance")
|
||||
|
||||
offers, _, _, _ := notifier.snapshot()
|
||||
require.Len(t, offers, 1, "the pending offer must be raised exactly once")
|
||||
assert.Equal(t, DecisionPending, offers[0].Decision, "the raised offer starts pending")
|
||||
}
|
||||
|
||||
func TestAskModeDeclineKeepsPayloadOut(t *testing.T) {
|
||||
srv, client, notifier := startTestServer(t, ModeAsk, staticResolver{key: testPeer})
|
||||
|
||||
go func() {
|
||||
for {
|
||||
offers, _, _, _ := notifier.snapshot()
|
||||
if len(offers) > 0 {
|
||||
srv.Offers().Decide(offers[0].ID, DecisionDeclined)
|
||||
return
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
}()
|
||||
|
||||
id, err := client.Send(context.Background(), testAddr, []Payload{filePayload(t, "x.bin", []byte("data"))}, nil)
|
||||
require.ErrorIs(t, err, ErrDeclined, "sender must see the decline")
|
||||
|
||||
_, statErr := os.Stat(srv.Spool().Path(id, 0))
|
||||
assert.True(t, os.IsNotExist(statErr), "declined payload must never be staged")
|
||||
}
|
||||
|
||||
func TestOffModeRefusesOffer(t *testing.T) {
|
||||
_, client, notifier := startTestServer(t, ModeOff, staticResolver{key: testPeer})
|
||||
|
||||
_, err := client.Send(context.Background(), testAddr, []Payload{filePayload(t, "x.bin", []byte("data"))}, nil)
|
||||
require.ErrorIs(t, err, ErrRefused, "an off receiver must refuse the offer")
|
||||
|
||||
offers, _, _, _ := notifier.snapshot()
|
||||
assert.Empty(t, offers, "a refused offer must not reach the user")
|
||||
}
|
||||
|
||||
func TestUnknownSenderIsRefused(t *testing.T) {
|
||||
_, client, _ := startTestServer(t, ModeAutoAccept, staticResolver{unknown: true})
|
||||
|
||||
_, err := client.Send(context.Background(), testAddr, []Payload{filePayload(t, "x.bin", []byte("data"))}, nil)
|
||||
require.ErrorIs(t, err, ErrRefused, "an unresolvable source address must be refused")
|
||||
}
|
||||
|
||||
func TestUploadResumesFromConfirmedOffset(t *testing.T) {
|
||||
srv, client, _ := startTestServer(t, ModeAutoAccept, staticResolver{key: testPeer})
|
||||
|
||||
content := []byte(strings.Repeat("resume", 500))
|
||||
payload := filePayload(t, "big.bin", content)
|
||||
|
||||
offer := srv.Offers().Add(testPeer, "", []FileMeta{payload.Meta}, DecisionAccepted)
|
||||
require.NoError(t, srv.Spool().Prepare(offer.ID))
|
||||
|
||||
half := int64(len(content) / 2)
|
||||
_, err := srv.Spool().Write(offer.ID, 0, 0, strings.NewReader(string(content[:half])), half)
|
||||
require.NoError(t, err)
|
||||
|
||||
srv.mu.RLock()
|
||||
base := "http://" + srv.listener.Addr().String()
|
||||
srv.mu.RUnlock()
|
||||
|
||||
confirmed, err := client.confirmedOffset(context.Background(), base, offer.ID, 0)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, half, confirmed, "receiver must report the staged prefix")
|
||||
|
||||
require.NoError(t, client.putFile(context.Background(), base, offer.ID, 0, payload, confirmed, nil))
|
||||
|
||||
staged, err := os.ReadFile(srv.Spool().Path(offer.ID, 0))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, content, staged, "resumed upload must reconstruct the full payload")
|
||||
}
|
||||
|
||||
func TestUploadIsBoundedByAnnouncedSize(t *testing.T) {
|
||||
srv, _, _ := startTestServer(t, ModeAutoAccept, staticResolver{key: testPeer})
|
||||
|
||||
// The request is issued raw: the stdlib client refuses to send a body that
|
||||
announced := int64(10)
|
||||
offer := srv.Offers().Add(testPeer, "", []FileMeta{{Name: "lie.bin", Size: announced}}, DecisionAccepted)
|
||||
require.NoError(t, srv.Spool().Prepare(offer.ID))
|
||||
|
||||
srv.mu.RLock()
|
||||
addr := srv.listener.Addr().String()
|
||||
srv.mu.RUnlock()
|
||||
|
||||
conn, err := net.Dial("tcp", addr)
|
||||
require.NoError(t, err)
|
||||
defer func() {
|
||||
_ = conn.Close()
|
||||
}()
|
||||
|
||||
oversized := strings.Repeat("A", 100)
|
||||
request := "PUT /v1/offers/" + string(offer.ID) + "/files/0?offset=0 HTTP/1.1\r\n" +
|
||||
"Host: filedrop\r\nContent-Length: 100\r\nConnection: close\r\n\r\n" + oversized
|
||||
_, err = conn.Write([]byte(request))
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = io.ReadAll(conn)
|
||||
require.NoError(t, err)
|
||||
|
||||
staged, err := os.ReadFile(srv.Spool().Path(offer.ID, 0))
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, staged, int(announced), "staged size must be capped at the announced size")
|
||||
}
|
||||
|
||||
func TestCancelWithdrawsPendingOffer(t *testing.T) {
|
||||
srv, client, notifier := startTestServer(t, ModeAsk, staticResolver{key: testPeer})
|
||||
|
||||
sendCtx, cancelSend := context.WithCancel(context.Background())
|
||||
defer cancelSend()
|
||||
|
||||
go func() {
|
||||
_, _ = client.Send(sendCtx, testAddr, []Payload{filePayload(t, "x.bin", []byte("data"))}, nil)
|
||||
}()
|
||||
|
||||
var id OfferID
|
||||
require.Eventually(t, func() bool {
|
||||
offers, _, _, _ := notifier.snapshot()
|
||||
if len(offers) == 0 {
|
||||
return false
|
||||
}
|
||||
id = offers[0].ID
|
||||
return true
|
||||
}, 3*time.Second, 10*time.Millisecond, "offer must reach the receiver")
|
||||
|
||||
require.NoError(t, client.Cancel(context.Background(), testAddr, id))
|
||||
|
||||
_, ok := srv.Offers().Get(testPeer, id)
|
||||
assert.False(t, ok, "a withdrawn offer must be dropped")
|
||||
|
||||
_, _, _, withdrawn := notifier.snapshot()
|
||||
require.Len(t, withdrawn, 1, "the consent prompt must be withdrawn")
|
||||
assert.Equal(t, id, withdrawn[0].ID)
|
||||
}
|
||||
|
||||
func TestTextPayloadStaysInline(t *testing.T) {
|
||||
srv, client, _ := startTestServer(t, ModeAutoAccept, staticResolver{key: testPeer})
|
||||
|
||||
id, err := client.Send(context.Background(), testAddr, []Payload{TextPayload("snippet", "hello peer")}, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
offer, ok := srv.Offers().Get(testPeer, id)
|
||||
require.True(t, ok)
|
||||
require.Len(t, offer.Files, 1)
|
||||
assert.Equal(t, "hello peer", offer.Files[0].Text, "text must arrive in the offer itself")
|
||||
assert.Equal(t, StateCompleted, offer.State, "a text-only offer completes without an upload")
|
||||
|
||||
_, statErr := os.Stat(srv.Spool().Path(id, 0))
|
||||
assert.True(t, os.IsNotExist(statErr), "text payloads must not be written to the spool")
|
||||
}
|
||||
|
||||
func TestOfferExpiresWithoutDecision(t *testing.T) {
|
||||
policy := NewPolicyStore(testProfile)
|
||||
require.NoError(t, policy.SetMode(ModeAsk))
|
||||
|
||||
srv, err := NewServer(ServerConfig{
|
||||
SpoolDir: t.TempDir(),
|
||||
Policy: policy,
|
||||
Resolver: staticResolver{key: testPeer},
|
||||
OfferTTL: 100 * time.Millisecond,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
offer := srv.Offers().Add(testPeer, "", []FileMeta{{Name: "x", Size: 1}}, DecisionPending)
|
||||
|
||||
awaited, err := srv.Offers().Await(context.Background(), testPeer, offer.ID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, DecisionExpired, awaited.Decision, "an unanswered offer must expire")
|
||||
assert.Equal(t, StateExpired, awaited.State)
|
||||
}
|
||||
|
||||
func TestDecideIsFinal(t *testing.T) {
|
||||
store := NewOfferStore(time.Minute)
|
||||
offer := store.Add(testPeer, "", []FileMeta{{Name: "x", Size: 1}}, DecisionPending)
|
||||
|
||||
_, ok := store.Decide(offer.ID, DecisionDeclined)
|
||||
require.True(t, ok, "the first decision must be recorded")
|
||||
|
||||
_, ok = store.Decide(offer.ID, DecisionAccepted)
|
||||
assert.False(t, ok, "a decided offer must not be revived")
|
||||
|
||||
current, ok := store.Get(testPeer, offer.ID)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, DecisionDeclined, current.Decision, "the original decision must stand")
|
||||
}
|
||||
|
||||
func TestOfferIsScopedToItsSender(t *testing.T) {
|
||||
store := NewOfferStore(time.Minute)
|
||||
offer := store.Add(testPeer, "", []FileMeta{{Name: "x", Size: 1}}, DecisionAccepted)
|
||||
|
||||
_, ok := store.Get("other-peer", offer.ID)
|
||||
assert.False(t, ok, "another peer must not see the offer")
|
||||
|
||||
_, err := store.Await(context.Background(), "other-peer", offer.ID)
|
||||
assert.ErrorIs(t, err, ErrOfferNotFound, "another peer must not poll the offer")
|
||||
}
|
||||
|
||||
func TestPolicyEvaluation(t *testing.T) {
|
||||
store := NewPolicyStore(testProfile)
|
||||
require.NoError(t, store.Set(Policy{Mode: ModeAsk}))
|
||||
|
||||
assert.Equal(t, ModeAsk, store.Evaluate(testPeer), "unknown senders are asked about")
|
||||
|
||||
require.NoError(t, store.SetSenderRule(testPeer, SenderRuleBlock))
|
||||
assert.Equal(t, ModeOff, store.Evaluate(testPeer), "a blocked sender is refused")
|
||||
|
||||
require.NoError(t, store.SetSenderRule(testPeer, SenderRuleAlwaysAccept))
|
||||
assert.Equal(t, ModeAutoAccept, store.Evaluate(testPeer), "an always-accept sender skips the prompt")
|
||||
|
||||
require.NoError(t, store.SetSenderRule(testPeer, SenderRuleDefault))
|
||||
assert.Equal(t, ModeAsk, store.Evaluate(testPeer), "clearing the rule restores the base mode")
|
||||
}
|
||||
|
||||
func TestPolicyRejectsUnknownModeAndDeniesOnCorruptRule(t *testing.T) {
|
||||
store := NewPolicyStore(testProfile)
|
||||
|
||||
require.Error(t, store.SetMode(Mode(200)), "an unknown mode must be rejected")
|
||||
assert.Equal(t, ModeAsk, store.Get().Mode, "the rejected mode must not be applied")
|
||||
|
||||
require.NoError(t, store.SetSenderRule(testPeer, SenderRule(200)))
|
||||
assert.Equal(t, ModeOff, store.Evaluate(testPeer), "an unrecognized rule must deny")
|
||||
}
|
||||
|
||||
type memStore struct {
|
||||
mu sync.Mutex
|
||||
sections map[string][]byte
|
||||
loadErr error
|
||||
}
|
||||
|
||||
func newMemStore() *memStore {
|
||||
return &memStore{sections: map[string][]byte{}}
|
||||
}
|
||||
|
||||
func (s *memStore) Get(namespace string, v any) (bool, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.loadErr != nil {
|
||||
return false, s.loadErr
|
||||
}
|
||||
raw, ok := s.sections[namespace]
|
||||
if !ok {
|
||||
return false, nil
|
||||
}
|
||||
return true, json.Unmarshal(raw, v)
|
||||
}
|
||||
|
||||
func (s *memStore) Put(namespace string, v any) error {
|
||||
raw, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.sections[namespace] = raw
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestPolicyIsScopedPerProfile(t *testing.T) {
|
||||
work, home := profilemanager.ID("work"), profilemanager.ID("home")
|
||||
workPrefs, homePrefs := newMemStore(), newMemStore()
|
||||
|
||||
workStore := LoadPolicyStore(work, workPrefs)
|
||||
require.NoError(t, workStore.SetMode(ModeOff))
|
||||
require.NoError(t, workStore.SetSenderRule(testPeer, SenderRuleBlock))
|
||||
|
||||
homeStore := LoadPolicyStore(home, homePrefs)
|
||||
assert.Equal(t, ModeAsk, homeStore.Get().Mode, "another profile keeps the default mode")
|
||||
assert.Equal(t, ModeAsk, homeStore.Evaluate(testPeer), "a block in one profile must not apply to another")
|
||||
|
||||
reloaded := LoadPolicyStore(work, workPrefs)
|
||||
assert.Equal(t, ModeOff, reloaded.Get().Mode, "the profile's mode must survive a reload")
|
||||
assert.Equal(t, ModeOff, reloaded.Evaluate(testPeer), "the profile's sender rule must survive a reload")
|
||||
}
|
||||
|
||||
func TestPolicyFallsBackToDefaultsOnLoadFailure(t *testing.T) {
|
||||
prefs := newMemStore()
|
||||
prefs.loadErr = errors.New("store unavailable")
|
||||
|
||||
store := LoadPolicyStore(testProfile, prefs)
|
||||
|
||||
assert.Equal(t, ModeAsk, store.Get().Mode, "an unreadable policy must not open the device up")
|
||||
assert.Equal(t, ModeAsk, store.Evaluate(testPeer), "the safe default applies to unknown senders")
|
||||
}
|
||||
|
||||
func TestPolicyRejectsStoredInvalidModeOnLoad(t *testing.T) {
|
||||
prefs := newMemStore()
|
||||
require.NoError(t, prefs.Put(namespacePolicy, Policy{Mode: Mode(200)}))
|
||||
|
||||
store := LoadPolicyStore(testProfile, prefs)
|
||||
|
||||
assert.Equal(t, ModeAsk, store.Get().Mode, "a corrupted stored mode must fall back to the default")
|
||||
}
|
||||
|
||||
func TestStoreKeepsPolicyAndHistoryApart(t *testing.T) {
|
||||
prefs := newMemStore()
|
||||
|
||||
mgr, err := NewManager(ManagerConfig{Profile: testProfile, DataDir: t.TempDir(), Store: prefs})
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, mgr.Policy().SetMode(ModeAutoAccept))
|
||||
require.NoError(t, mgr.SetDestinationDir("/tmp/received"))
|
||||
mgr.history.Upsert(Transfer{ID: "offer-1", PeerKey: testPeer, State: StateCompleted})
|
||||
require.NoError(t, mgr.Close())
|
||||
|
||||
reloaded, err := NewManager(ManagerConfig{Profile: testProfile, DataDir: t.TempDir(), Store: prefs})
|
||||
require.NoError(t, err)
|
||||
defer func() { require.NoError(t, reloaded.Close()) }()
|
||||
|
||||
assert.Equal(t, ModeAutoAccept, reloaded.Policy().Get().Mode, "the policy must survive a reload")
|
||||
assert.Equal(t, "/tmp/received", reloaded.DestinationDir(), "the destination must survive a reload")
|
||||
require.Len(t, reloaded.Transfers(), 1, "the history must survive a reload")
|
||||
assert.Equal(t, OfferID("offer-1"), reloaded.Transfers()[0].ID)
|
||||
}
|
||||
|
||||
func TestHistoryDropsOldestTerminalEntriesOverCap(t *testing.T) {
|
||||
history := LoadHistory(newMemStore())
|
||||
|
||||
for i := 0; i < historyCap+10; i++ {
|
||||
history.Upsert(Transfer{ID: OfferID(fmt.Sprintf("offer-%d", i)), State: StateCompleted})
|
||||
}
|
||||
|
||||
entries := history.List()
|
||||
require.Len(t, entries, historyCap, "the log must stay bounded")
|
||||
assert.Equal(t, OfferID(fmt.Sprintf("offer-%d", historyCap+9)), entries[0].ID, "the newest entry stays")
|
||||
}
|
||||
|
||||
func TestHistoryKeepsLiveTransfersOverCap(t *testing.T) {
|
||||
history := LoadHistory(newMemStore())
|
||||
|
||||
history.Upsert(Transfer{ID: "live", State: StateTransferring})
|
||||
for i := 0; i < historyCap+5; i++ {
|
||||
history.Upsert(Transfer{ID: OfferID(fmt.Sprintf("done-%d", i)), State: StateCompleted})
|
||||
}
|
||||
|
||||
_, ok := history.Get("live")
|
||||
assert.True(t, ok, "a transfer still running must not be pruned")
|
||||
}
|
||||
|
||||
func TestHistorySettlesTransfersInterruptedByRestart(t *testing.T) {
|
||||
store := newMemStore()
|
||||
|
||||
history := LoadHistory(store)
|
||||
history.Upsert(Transfer{ID: "pending", State: StatePending})
|
||||
history.Upsert(Transfer{ID: "moving", State: StateTransferring})
|
||||
history.Upsert(Transfer{ID: "done", State: StateCompleted})
|
||||
history.Upsert(Transfer{ID: "refused", State: StateDeclined})
|
||||
|
||||
// A fresh load stands in for the next process: nothing survives to finish
|
||||
// whatever was still moving.
|
||||
reloaded := LoadHistory(store)
|
||||
|
||||
for _, tc := range []struct {
|
||||
id OfferID
|
||||
state State
|
||||
reason FailureReason
|
||||
}{
|
||||
{"pending", StateFailed, ReasonInterrupted},
|
||||
{"moving", StateFailed, ReasonInterrupted},
|
||||
{"done", StateCompleted, ReasonNone},
|
||||
{"refused", StateDeclined, ReasonNone},
|
||||
} {
|
||||
entry, ok := reloaded.Get(tc.id)
|
||||
require.True(t, ok, "entry %s must survive the reload", tc.id)
|
||||
assert.Equal(t, tc.state, entry.State, "state of %s", tc.id)
|
||||
assert.Equal(t, tc.reason, entry.Reason, "reason of %s", tc.id)
|
||||
}
|
||||
|
||||
// The settled states are written back, so a third start sees them as final
|
||||
// rather than settling them again.
|
||||
third := LoadHistory(store)
|
||||
entry, ok := third.Get("moving")
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, StateFailed, entry.State)
|
||||
}
|
||||
|
||||
func TestSpoolWriteTruncatesStaleTail(t *testing.T) {
|
||||
spool, err := NewSpool(t.TempDir())
|
||||
require.NoError(t, err)
|
||||
|
||||
id := OfferID("offer")
|
||||
require.NoError(t, spool.Prepare(id))
|
||||
|
||||
_, err = spool.Write(id, 0, 0, strings.NewReader("AAAAAAAAAA"), 10)
|
||||
require.NoError(t, err)
|
||||
|
||||
total, err := spool.Write(id, 0, 2, strings.NewReader("BB"), 10)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(4), total, "staged size follows the resumed write")
|
||||
|
||||
staged, err := os.ReadFile(spool.Path(id, 0))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "AABB", string(staged), "stale bytes past the offset must be dropped")
|
||||
}
|
||||
|
||||
func TestSpoolCleanupDropsStalePartials(t *testing.T) {
|
||||
spool, err := NewSpool(t.TempDir())
|
||||
require.NoError(t, err)
|
||||
|
||||
stale, fresh := OfferID("stale"), OfferID("fresh")
|
||||
require.NoError(t, spool.Prepare(stale))
|
||||
require.NoError(t, spool.Prepare(fresh))
|
||||
|
||||
old := time.Now().Add(-2 * time.Hour)
|
||||
require.NoError(t, os.Chtimes(spool.OfferDir(stale), old, old))
|
||||
|
||||
spool.Cleanup(time.Hour, time.Now())
|
||||
|
||||
_, err = os.Stat(spool.OfferDir(stale))
|
||||
assert.True(t, os.IsNotExist(err), "the stale offer dir must be removed")
|
||||
|
||||
_, err = os.Stat(spool.OfferDir(fresh))
|
||||
assert.NoError(t, err, "a recent offer dir must survive")
|
||||
}
|
||||
|
||||
func TestParseOfferPath(t *testing.T) {
|
||||
tests := []struct {
|
||||
path string
|
||||
id OfferID
|
||||
index int
|
||||
hasIndex bool
|
||||
wantErr bool
|
||||
}{
|
||||
{path: "/v1/offers/abc", id: "abc"},
|
||||
{path: "/v1/offers/abc/files/3", id: "abc", index: 3, hasIndex: true},
|
||||
{path: "/v1/offers/", wantErr: true},
|
||||
{path: "/v1/offers/abc/files", wantErr: true},
|
||||
{path: "/v1/offers/abc/other/1", wantErr: true},
|
||||
{path: "/v1/offers/abc/files/-1", wantErr: true},
|
||||
{path: "/v1/offers/abc/files/x", wantErr: true},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.path, func(t *testing.T) {
|
||||
id, index, hasIndex, err := parseOfferPath(tc.path)
|
||||
if tc.wantErr {
|
||||
assert.Error(t, err)
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tc.id, id)
|
||||
assert.Equal(t, tc.index, index)
|
||||
assert.Equal(t, tc.hasIndex, hasIndex)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateOffer(t *testing.T) {
|
||||
assert.Error(t, validateOffer(nil), "an empty offer is invalid")
|
||||
assert.Error(t, validateOffer([]FileMeta{{Name: "x", Size: -1}}), "a negative size is invalid")
|
||||
assert.Error(t, validateOffer(make([]FileMeta, MaxOfferFiles+1)), "too many files is invalid")
|
||||
assert.Error(t, validateOffer([]FileMeta{{
|
||||
Name: "x", Kind: KindText, Text: strings.Repeat("a", MaxInlineTextSize+1),
|
||||
}}), "oversized inline text is invalid")
|
||||
|
||||
assert.NoError(t, validateOffer([]FileMeta{{Name: "x", Size: 10}}))
|
||||
}
|
||||
|
||||
func TestStopIsIdempotent(t *testing.T) {
|
||||
srv, err := NewServer(ServerConfig{
|
||||
SpoolDir: t.TempDir(),
|
||||
Policy: NewPolicyStore(testProfile),
|
||||
Resolver: staticResolver{key: testPeer},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
require.NoError(t, srv.Stop(), "stopping a server that never started is a no-op")
|
||||
|
||||
require.NoError(t, srv.Start(context.Background(), netip.AddrPortFrom(netip.AddrFrom4([4]byte{127, 0, 0, 1}), 0)))
|
||||
require.NoError(t, srv.Stop())
|
||||
require.NoError(t, srv.Stop(), "the second stop must also be a no-op")
|
||||
}
|
||||
|
||||
func TestStartRejectsSecondStart(t *testing.T) {
|
||||
srv, err := NewServer(ServerConfig{
|
||||
SpoolDir: t.TempDir(),
|
||||
Policy: NewPolicyStore(testProfile),
|
||||
Resolver: staticResolver{key: testPeer},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
addr := netip.AddrPortFrom(netip.AddrFrom4([4]byte{127, 0, 0, 1}), 0)
|
||||
require.NoError(t, srv.Start(context.Background(), addr))
|
||||
t.Cleanup(func() {
|
||||
require.NoError(t, srv.Stop())
|
||||
})
|
||||
|
||||
err = srv.Start(context.Background(), addr)
|
||||
require.Error(t, err, "a running server must reject a second start")
|
||||
|
||||
srv.mu.RLock()
|
||||
running := srv.httpServer != nil && srv.listener != nil
|
||||
srv.mu.RUnlock()
|
||||
assert.True(t, running, "the original listener must survive the rejected start")
|
||||
}
|
||||
|
||||
func TestNewServerRequiresResolver(t *testing.T) {
|
||||
_, err := NewServer(ServerConfig{SpoolDir: t.TempDir(), Policy: NewPolicyStore(testProfile)})
|
||||
require.Error(t, err, "a server without peer resolution must not be constructed")
|
||||
}
|
||||
|
||||
func TestNewServerRequiresPolicy(t *testing.T) {
|
||||
_, err := NewServer(ServerConfig{SpoolDir: t.TempDir(), Resolver: staticResolver{key: testPeer}})
|
||||
require.Error(t, err, "a server without a profile policy must not be constructed")
|
||||
}
|
||||
|
||||
func TestNewClientRequiresDialer(t *testing.T) {
|
||||
_, err := NewClient(ClientConfig{})
|
||||
require.Error(t, err, "a client without a dialer must not be constructed")
|
||||
}
|
||||
|
||||
func TestSendRejectsEmptyPayloadSet(t *testing.T) {
|
||||
_, client, _ := startTestServer(t, ModeAutoAccept, staticResolver{key: testPeer})
|
||||
|
||||
_, err := client.Send(context.Background(), testAddr, nil, nil)
|
||||
assert.True(t, errors.Is(err, ErrInvalidOffer), "sending nothing is an invalid offer")
|
||||
}
|
||||
|
||||
func TestServerFallsBackWhenPortBusy(t *testing.T) {
|
||||
blocker, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
require.NoError(t, err, "blocker listener must bind")
|
||||
defer func() {
|
||||
require.NoError(t, blocker.Close())
|
||||
}()
|
||||
busyPort := uint16(blocker.Addr().(*net.TCPAddr).Port)
|
||||
|
||||
srv, err := NewServer(ServerConfig{
|
||||
SpoolDir: t.TempDir(),
|
||||
Policy: NewPolicyStore(testProfile),
|
||||
Resolver: staticResolver{key: testPeer},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
addr := netip.AddrPortFrom(netip.AddrFrom4([4]byte{127, 0, 0, 1}), busyPort)
|
||||
require.NoError(t, srv.Start(context.Background(), addr), "start must fall back instead of failing")
|
||||
t.Cleanup(func() {
|
||||
require.NoError(t, srv.Stop())
|
||||
})
|
||||
|
||||
bound := srv.BoundPort()
|
||||
assert.NotZero(t, bound, "fallback must report the bound port")
|
||||
assert.NotEqual(t, busyPort, bound, "fallback must pick a different port")
|
||||
}
|
||||
|
||||
func TestPortRegistryAwait(t *testing.T) {
|
||||
reg := NewPortRegistry()
|
||||
|
||||
reg.Set(testPeer, 5000)
|
||||
port, changed := reg.Await(context.Background(), testPeer, 0)
|
||||
assert.True(t, changed, "known differing port must return immediately")
|
||||
assert.Equal(t, uint16(5000), port)
|
||||
|
||||
go func() {
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
reg.Set(testPeer, 5000)
|
||||
}()
|
||||
_, changed = reg.Await(context.Background(), testPeer, 5000)
|
||||
assert.False(t, changed, "an advertisement equal to the used port must release the waiter as unchanged")
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
|
||||
defer cancel()
|
||||
_, changed = reg.Await(ctx, testPeer, 5000)
|
||||
assert.False(t, changed, "timeout without advertisement must report unchanged")
|
||||
}
|
||||
|
||||
// senderManager builds a send-only manager whose dialer reaches the test server only
|
||||
// on realPort; other ports behave per defaultPortBehavior ("refuse" or "hang").
|
||||
func senderManager(t *testing.T, serverAddr string, realPort uint16, defaultPortBehavior string) *Manager {
|
||||
t.Helper()
|
||||
|
||||
mgr, err := NewManager(ManagerConfig{Profile: testProfile, DataDir: t.TempDir()})
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() {
|
||||
require.NoError(t, mgr.Close())
|
||||
})
|
||||
|
||||
mgr.SetTunnel(func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
ap, err := netip.ParseAddrPort(addr)
|
||||
require.NoError(t, err, "dialer must receive a valid addr")
|
||||
if ap.Port() == realPort {
|
||||
var d net.Dialer
|
||||
return d.DialContext(ctx, network, serverAddr)
|
||||
}
|
||||
if defaultPortBehavior == "hang" {
|
||||
<-ctx.Done()
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
return nil, &net.OpError{Op: "dial", Net: network, Err: errors.New("connection refused")}
|
||||
}, "sender")
|
||||
|
||||
return mgr
|
||||
}
|
||||
|
||||
func waitForState(t *testing.T, mgr *Manager, id OfferID, want State) {
|
||||
t.Helper()
|
||||
require.Eventually(t, func() bool {
|
||||
tr, ok := mgr.history.Get(id)
|
||||
return ok && tr.State == want
|
||||
}, 10*time.Second, 20*time.Millisecond, "transfer must reach state %s", want)
|
||||
}
|
||||
|
||||
func TestSendRetriesOnAdvertisedPort(t *testing.T) {
|
||||
srv, _, _ := startTestServer(t, ModeAutoAccept, staticResolver{key: PeerKey("sender-key")})
|
||||
srv.mu.RLock()
|
||||
serverAddr := srv.listener.Addr().String()
|
||||
srv.mu.RUnlock()
|
||||
realPort := srv.BoundPort()
|
||||
|
||||
mgr := senderManager(t, serverAddr, realPort, "refuse")
|
||||
|
||||
id, err := mgr.Send(testPeer, "receiver", netip.AddrFrom4([4]byte{100, 64, 0, 9}), []Payload{TextPayload("t", "hello")})
|
||||
require.NoError(t, err)
|
||||
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
mgr.Ports().Set(testPeer, realPort)
|
||||
|
||||
waitForState(t, mgr, id, StateCompleted)
|
||||
}
|
||||
|
||||
func TestSendAbortsHangingAttemptOnAdvertisedPort(t *testing.T) {
|
||||
srv, _, _ := startTestServer(t, ModeAutoAccept, staticResolver{key: PeerKey("sender-key")})
|
||||
srv.mu.RLock()
|
||||
serverAddr := srv.listener.Addr().String()
|
||||
srv.mu.RUnlock()
|
||||
realPort := srv.BoundPort()
|
||||
|
||||
mgr := senderManager(t, serverAddr, realPort, "hang")
|
||||
|
||||
id, err := mgr.Send(testPeer, "receiver", netip.AddrFrom4([4]byte{100, 64, 0, 9}), []Payload{TextPayload("t", "hello")})
|
||||
require.NoError(t, err)
|
||||
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
mgr.Ports().Set(testPeer, realPort)
|
||||
|
||||
waitForState(t, mgr, id, StateCompleted)
|
||||
}
|
||||
|
||||
func TestSendFailsWhenSignalConfirmsUsedPort(t *testing.T) {
|
||||
mgr := senderManager(t, "127.0.0.1:1", 1, "refuse")
|
||||
|
||||
id, err := mgr.Send(testPeer, "receiver", netip.AddrFrom4([4]byte{100, 64, 0, 9}), []Payload{TextPayload("t", "hello")})
|
||||
require.NoError(t, err)
|
||||
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
mgr.Ports().Set(testPeer, 0)
|
||||
|
||||
waitForState(t, mgr, id, StateFailed)
|
||||
}
|
||||
205
client/internal/filedrop/history.go
Normal file
205
client/internal/filedrop/history.go
Normal file
@@ -0,0 +1,205 @@
|
||||
package filedrop
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"slices"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// The transfer directions.
|
||||
const (
|
||||
DirectionReceived Direction = iota
|
||||
DirectionSent
|
||||
)
|
||||
|
||||
// The failure reasons a transfer can end with; None accompanies every other state.
|
||||
const (
|
||||
ReasonNone FailureReason = iota
|
||||
// ReasonUnreachable marks a transport-level failure: nothing listens on the
|
||||
// peer's file drop port, so the client is old or receiving is off.
|
||||
ReasonUnreachable
|
||||
// ReasonInterrupted marks a transfer that was still moving when the process
|
||||
// stopped; nothing survived to finish or resume it.
|
||||
ReasonInterrupted
|
||||
)
|
||||
|
||||
const historyCap = 30
|
||||
|
||||
// Direction tells whether a transfer was sent by this device or received on it.
|
||||
type Direction uint8
|
||||
|
||||
// FailureReason classifies why a transfer failed, when it is known.
|
||||
type FailureReason uint8
|
||||
|
||||
// String implements fmt.Stringer.
|
||||
func (d Direction) String() string {
|
||||
switch d {
|
||||
case DirectionReceived:
|
||||
return "received"
|
||||
case DirectionSent:
|
||||
return "sent"
|
||||
default:
|
||||
return fmt.Sprintf("unknown(%d)", uint8(d))
|
||||
}
|
||||
}
|
||||
|
||||
// Transfer is one history entry: a sent or received offer with its outcome.
|
||||
type Transfer struct {
|
||||
ID OfferID `json:"id"`
|
||||
Direction Direction `json:"direction"`
|
||||
PeerKey PeerKey `json:"peerKey"`
|
||||
PeerName string `json:"peerName"`
|
||||
Files []FileMeta `json:"files"`
|
||||
State State `json:"state"`
|
||||
Transferred int64 `json:"transferred"`
|
||||
TotalSize int64 `json:"totalSize"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
DeliveredPaths []string `json:"deliveredPaths,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Reason FailureReason `json:"reason,omitempty"`
|
||||
}
|
||||
|
||||
// History is the persisted transfer log of one profile, newest first.
|
||||
type History struct {
|
||||
mu sync.RWMutex
|
||||
store Store
|
||||
entries []Transfer
|
||||
}
|
||||
|
||||
// LoadHistory reads the stored transfer log, starting empty when unreadable.
|
||||
func LoadHistory(store Store) *History {
|
||||
h := &History{store: store}
|
||||
|
||||
var entries []Transfer
|
||||
if err := loadSection(store, namespaceHistory, &entries); err != nil {
|
||||
log.Warnf("failed to read file drop history, starting empty: %v", err)
|
||||
return h
|
||||
}
|
||||
h.entries = entries
|
||||
if h.settleInterrupted() {
|
||||
h.persist()
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
func (t Transfer) terminal() bool {
|
||||
switch t.State {
|
||||
case StateCompleted, StateDeclined, StateExpired, StateCancelled, StateFailed:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (t Transfer) clone() Transfer {
|
||||
c := t
|
||||
c.Files = slices.Clone(t.Files)
|
||||
c.DeliveredPaths = slices.Clone(t.DeliveredPaths)
|
||||
return c
|
||||
}
|
||||
|
||||
// Upsert inserts or replaces the entry with the same ID and persists the log.
|
||||
func (h *History) Upsert(t Transfer) {
|
||||
t.UpdatedAt = time.Now()
|
||||
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
|
||||
if i := h.indexOf(t.ID); i >= 0 {
|
||||
h.entries[i] = t
|
||||
} else {
|
||||
h.entries = slices.Insert(h.entries, 0, t)
|
||||
h.prune()
|
||||
}
|
||||
h.persist()
|
||||
}
|
||||
|
||||
// SetProgress updates the transferred byte count in memory only.
|
||||
func (h *History) SetProgress(id OfferID, transferred int64) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
|
||||
if i := h.indexOf(id); i >= 0 {
|
||||
h.entries[i].Transferred = transferred
|
||||
h.entries[i].UpdatedAt = time.Now()
|
||||
if h.entries[i].State == StatePending {
|
||||
h.entries[i].State = StateTransferring
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get returns the entry with the given ID.
|
||||
func (h *History) Get(id OfferID) (Transfer, bool) {
|
||||
h.mu.RLock()
|
||||
defer h.mu.RUnlock()
|
||||
|
||||
if i := h.indexOf(id); i >= 0 {
|
||||
return h.entries[i].clone(), true
|
||||
}
|
||||
return Transfer{}, false
|
||||
}
|
||||
|
||||
// List returns every entry, newest first.
|
||||
func (h *History) List() []Transfer {
|
||||
h.mu.RLock()
|
||||
defer h.mu.RUnlock()
|
||||
|
||||
list := make([]Transfer, len(h.entries))
|
||||
for i, e := range h.entries {
|
||||
list[i] = e.clone()
|
||||
}
|
||||
return list
|
||||
}
|
||||
|
||||
// Delete removes one entry and persists the log.
|
||||
func (h *History) Delete(id OfferID) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
|
||||
if i := h.indexOf(id); i >= 0 {
|
||||
h.entries = slices.Delete(h.entries, i, i+1)
|
||||
h.persist()
|
||||
}
|
||||
}
|
||||
|
||||
func (h *History) indexOf(id OfferID) int {
|
||||
return slices.IndexFunc(h.entries, func(t Transfer) bool { return t.ID == id })
|
||||
}
|
||||
|
||||
func (h *History) prune() {
|
||||
if len(h.entries) <= historyCap {
|
||||
return
|
||||
}
|
||||
for i := len(h.entries) - 1; i >= 0 && len(h.entries) > historyCap; i-- {
|
||||
if h.entries[i].terminal() {
|
||||
h.entries = slices.Delete(h.entries, i, i+1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// settleInterrupted closes out transfers that were still moving when the
|
||||
// process died. Nothing is left to finish them, so left alone they would sit in
|
||||
// the log as permanently pending. Reports whether anything changed.
|
||||
func (h *History) settleInterrupted() bool {
|
||||
changed := false
|
||||
for i, t := range h.entries {
|
||||
if t.terminal() {
|
||||
continue
|
||||
}
|
||||
h.entries[i].State = StateFailed
|
||||
h.entries[i].Reason = ReasonInterrupted
|
||||
h.entries[i].UpdatedAt = time.Now()
|
||||
changed = true
|
||||
}
|
||||
return changed
|
||||
}
|
||||
|
||||
func (h *History) persist() {
|
||||
if err := saveSection(h.store, namespaceHistory, h.entries); err != nil {
|
||||
log.Warnf("failed to write file drop history: %v", err)
|
||||
}
|
||||
}
|
||||
221
client/internal/filedrop/http.go
Normal file
221
client/internal/filedrop/http.go
Normal file
@@ -0,0 +1,221 @@
|
||||
package filedrop
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// httpTransport adapts the receiver to plain HTTP/1.1 over the tunnel. It only
|
||||
// parses requests, maps domain errors to status codes, and encodes responses.
|
||||
type httpTransport struct {
|
||||
recv *receiver
|
||||
}
|
||||
|
||||
func (t *httpTransport) routes() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc(pathOffers, t.handleOffers)
|
||||
mux.HandleFunc(pathOffersSlash, t.handleOffer)
|
||||
return mux
|
||||
}
|
||||
|
||||
func (t *httpTransport) handleOffers(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
|
||||
return
|
||||
}
|
||||
|
||||
sender, ok := t.identify(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
var req OfferRequest
|
||||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, maxOfferBodySize)).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "malformed offer")
|
||||
return
|
||||
}
|
||||
|
||||
offer, err := t.recv.submitOffer(sender, req)
|
||||
if err != nil {
|
||||
writeDomainError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
status := http.StatusAccepted
|
||||
if offer.Decision == DecisionAccepted {
|
||||
status = http.StatusCreated
|
||||
}
|
||||
writeJSON(w, status, OfferResponse{ID: offer.ID, Decision: offer.Decision})
|
||||
}
|
||||
|
||||
func (t *httpTransport) handleOffer(w http.ResponseWriter, r *http.Request) {
|
||||
sender, ok := t.identify(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
id, index, hasIndex, err := parseOfferPath(r.URL.Path)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, "unknown path")
|
||||
return
|
||||
}
|
||||
|
||||
if !hasIndex {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
t.handleOfferStatus(w, r, sender, id)
|
||||
case http.MethodDelete:
|
||||
t.handleOfferCancel(w, sender, id)
|
||||
default:
|
||||
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
switch r.Method {
|
||||
case http.MethodPut:
|
||||
t.handleUpload(w, r, sender, id, index)
|
||||
case http.MethodHead:
|
||||
t.handleUploadProbe(w, sender, id, index)
|
||||
default:
|
||||
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
|
||||
}
|
||||
}
|
||||
|
||||
func (t *httpTransport) handleOfferStatus(w http.ResponseWriter, r *http.Request, sender senderIdentity, id OfferID) {
|
||||
offer, err := t.recv.awaitDecision(r.Context(), sender, id)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrOfferNotFound) {
|
||||
writeDomainError(w, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, OfferResponse{ID: offer.ID, Decision: offer.Decision})
|
||||
}
|
||||
|
||||
func (t *httpTransport) handleOfferCancel(w http.ResponseWriter, sender senderIdentity, id OfferID) {
|
||||
if err := t.recv.withdraw(sender, id); err != nil {
|
||||
writeDomainError(w, err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (t *httpTransport) handleUploadProbe(w http.ResponseWriter, sender senderIdentity, id OfferID, index int) {
|
||||
received, err := t.recv.receivedBytes(sender, id, index)
|
||||
if err != nil {
|
||||
writeDomainError(w, err)
|
||||
return
|
||||
}
|
||||
w.Header().Set(HeaderReceivedBytes, strconv.FormatInt(received, 10))
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
func (t *httpTransport) handleUpload(w http.ResponseWriter, r *http.Request, sender senderIdentity, id OfferID, index int) {
|
||||
offset, err := parseOffset(r.URL.Query().Get("offset"))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if err := t.recv.upload(sender, id, index, offset, r.Body); err != nil {
|
||||
writeDomainError(w, err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (t *httpTransport) identify(w http.ResponseWriter, r *http.Request) (senderIdentity, bool) {
|
||||
host, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||
if err != nil {
|
||||
host = r.RemoteAddr
|
||||
}
|
||||
|
||||
addr, err := netip.ParseAddr(host)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusForbidden, "unknown sender")
|
||||
return senderIdentity{}, false
|
||||
}
|
||||
|
||||
sender, ok := t.recv.identify(addr)
|
||||
if !ok {
|
||||
writeError(w, http.StatusForbidden, "unknown sender")
|
||||
return senderIdentity{}, false
|
||||
}
|
||||
return sender, true
|
||||
}
|
||||
|
||||
func writeDomainError(w http.ResponseWriter, err error) {
|
||||
switch {
|
||||
case errors.Is(err, ErrRefused), errors.Is(err, ErrNotAccepted), errors.Is(err, ErrUnknownPeer):
|
||||
writeError(w, http.StatusForbidden, err.Error())
|
||||
case errors.Is(err, ErrOfferNotFound):
|
||||
writeError(w, http.StatusNotFound, err.Error())
|
||||
case errors.Is(err, ErrInvalidOffer):
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
case errors.Is(err, ErrStorage):
|
||||
writeError(w, http.StatusInsufficientStorage, err.Error())
|
||||
default:
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func parseOfferPath(path string) (OfferID, int, bool, error) {
|
||||
rest := strings.TrimPrefix(path, pathOffersSlash)
|
||||
if rest == "" || rest == path {
|
||||
return "", 0, false, fmt.Errorf("not an offer path")
|
||||
}
|
||||
|
||||
parts := strings.Split(rest, "/")
|
||||
if parts[0] == "" {
|
||||
return "", 0, false, fmt.Errorf("missing offer id")
|
||||
}
|
||||
id := OfferID(parts[0])
|
||||
|
||||
switch len(parts) {
|
||||
case 1:
|
||||
return id, 0, false, nil
|
||||
case 3:
|
||||
if parts[1] != segmentFiles {
|
||||
return "", 0, false, fmt.Errorf("unknown sub-resource %q", parts[1])
|
||||
}
|
||||
index, err := strconv.Atoi(parts[2])
|
||||
if err != nil || index < 0 {
|
||||
return "", 0, false, fmt.Errorf("invalid file index")
|
||||
}
|
||||
return id, index, true, nil
|
||||
default:
|
||||
return "", 0, false, fmt.Errorf("unknown offer path")
|
||||
}
|
||||
}
|
||||
|
||||
func parseOffset(raw string) (int64, error) {
|
||||
if raw == "" {
|
||||
return 0, nil
|
||||
}
|
||||
offset, err := strconv.ParseInt(raw, 10, 64)
|
||||
if err != nil || offset < 0 {
|
||||
return 0, fmt.Errorf("invalid offset")
|
||||
}
|
||||
return offset, nil
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, body any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
if err := json.NewEncoder(w).Encode(body); err != nil {
|
||||
log.Debugf("write file drop response: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func writeError(w http.ResponseWriter, status int, message string) {
|
||||
writeJSON(w, status, map[string]string{"error": message})
|
||||
}
|
||||
660
client/internal/filedrop/manager.go
Normal file
660
client/internal/filedrop/manager.go
Normal file
@@ -0,0 +1,660 @@
|
||||
package filedrop
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"golang.zx2c4.com/wireguard/tun/netstack"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
||||
)
|
||||
|
||||
// The event kinds. Progress is not an event: live transfers are polled.
|
||||
const (
|
||||
EventOffer EventKind = iota
|
||||
EventCompleted
|
||||
EventFailed
|
||||
EventWithdrawn
|
||||
)
|
||||
|
||||
// portSignalGrace bounds how long a failed attempt waits for one signal message
|
||||
// that may advertise the receiver's actual port before giving up.
|
||||
const portSignalGrace = 3 * time.Second
|
||||
|
||||
// ErrNotConnected indicates the operation needs a running tunnel.
|
||||
var ErrNotConnected = errors.New("not connected")
|
||||
|
||||
// EventKind classifies the events the manager surfaces to the platform layer.
|
||||
type EventKind uint8
|
||||
|
||||
// EventSink receives transfer events. Calls may come from server goroutines.
|
||||
type EventSink func(kind EventKind, transfer Transfer)
|
||||
|
||||
// ManagerConfig configures a per-profile file drop manager. Policy and history
|
||||
// live in Store; DataDir only holds the spool of partially received files,
|
||||
// which is disposable and never outlives an offer's TTL.
|
||||
type ManagerConfig struct {
|
||||
Profile profilemanager.ID
|
||||
DataDir string
|
||||
Store Store
|
||||
Events EventSink
|
||||
OfferTTL time.Duration
|
||||
}
|
||||
|
||||
type sendHandle struct {
|
||||
cancel context.CancelFunc
|
||||
ip netip.Addr
|
||||
addr netip.AddrPort
|
||||
remoteID OfferID
|
||||
}
|
||||
|
||||
// Manager owns one profile's file drop state.
|
||||
type Manager struct {
|
||||
mu sync.Mutex
|
||||
profile profilemanager.ID
|
||||
dataDir string
|
||||
store Store
|
||||
policy *PolicyStore
|
||||
history *History
|
||||
events EventSink
|
||||
offerTTL time.Duration
|
||||
|
||||
server *Server
|
||||
ports *PortRegistry
|
||||
dial DialFunc
|
||||
senderName string
|
||||
sends map[OfferID]*sendHandle
|
||||
sendWg sync.WaitGroup
|
||||
}
|
||||
|
||||
// NewManager loads or initializes the file drop state for one profile.
|
||||
func NewManager(cfg ManagerConfig) (*Manager, error) {
|
||||
if cfg.DataDir == "" {
|
||||
return nil, errors.New("data dir is required")
|
||||
}
|
||||
if err := os.MkdirAll(cfg.DataDir, 0o700); err != nil {
|
||||
return nil, fmt.Errorf("create file drop dir: %w", err)
|
||||
}
|
||||
|
||||
m := &Manager{
|
||||
profile: cfg.Profile,
|
||||
dataDir: cfg.DataDir,
|
||||
store: cfg.Store,
|
||||
policy: LoadPolicyStore(cfg.Profile, cfg.Store),
|
||||
history: LoadHistory(cfg.Store),
|
||||
events: cfg.Events,
|
||||
offerTTL: cfg.OfferTTL,
|
||||
ports: NewPortRegistry(),
|
||||
sends: make(map[OfferID]*sendHandle),
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// Profile returns the profile this manager belongs to.
|
||||
func (m *Manager) Profile() profilemanager.ID {
|
||||
return m.profile
|
||||
}
|
||||
|
||||
// Policy returns the receiving policy store.
|
||||
func (m *Manager) Policy() *PolicyStore {
|
||||
return m.policy
|
||||
}
|
||||
|
||||
// Ports returns the registry of peer-advertised listen ports; the engine feeds it
|
||||
// from incoming signal messages.
|
||||
func (m *Manager) Ports() *PortRegistry {
|
||||
return m.ports
|
||||
}
|
||||
|
||||
// ReceiverPort returns the port the receiver is actually bound to, 0 when stopped.
|
||||
func (m *Manager) ReceiverPort() uint16 {
|
||||
m.mu.Lock()
|
||||
server := m.server
|
||||
m.mu.Unlock()
|
||||
if server == nil {
|
||||
return 0
|
||||
}
|
||||
return server.BoundPort()
|
||||
}
|
||||
|
||||
// Transfers returns the history entries, newest first, with pending offers included.
|
||||
func (m *Manager) Transfers() []Transfer {
|
||||
return m.history.List()
|
||||
}
|
||||
|
||||
// DeleteTransfer removes a history entry. A live transfer is cancelled first.
|
||||
func (m *Manager) DeleteTransfer(id OfferID) {
|
||||
if t, ok := m.history.Get(id); ok && !t.terminal() {
|
||||
m.Cancel(id)
|
||||
}
|
||||
m.history.Delete(id)
|
||||
}
|
||||
|
||||
// DestinationDir returns the directory received files are delivered to.
|
||||
func (m *Manager) DestinationDir() string {
|
||||
return m.policy.DestinationDir()
|
||||
}
|
||||
|
||||
// SetDestinationDir persists the delivery directory.
|
||||
func (m *Manager) SetDestinationDir(dir string) error {
|
||||
return m.policy.SetDestinationDir(dir)
|
||||
}
|
||||
|
||||
// StartReceiver binds the receiving server on addr.
|
||||
func (m *Manager) StartReceiver(ctx context.Context, addr netip.AddrPort, netstackNet *netstack.Net, resolver PeerResolver) error {
|
||||
m.mu.Lock()
|
||||
if m.server != nil {
|
||||
m.mu.Unlock()
|
||||
return errors.New("receiver is already running")
|
||||
}
|
||||
m.mu.Unlock()
|
||||
|
||||
server, err := NewServer(ServerConfig{
|
||||
SpoolDir: filepath.Join(m.dataDir, "spool"),
|
||||
Policy: m.policy,
|
||||
Resolver: resolver,
|
||||
Notifier: m,
|
||||
OfferTTL: m.offerTTL,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("create receiver: %w", err)
|
||||
}
|
||||
if netstackNet != nil {
|
||||
server.SetNetstackNet(netstackNet)
|
||||
}
|
||||
|
||||
if err := server.Start(ctx, addr); err != nil {
|
||||
return fmt.Errorf("start receiver: %w", err)
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
m.server = server
|
||||
m.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
// AddReceiverListener serves the receiver on an additional address, such as IPv6.
|
||||
func (m *Manager) AddReceiverListener(ctx context.Context, addr netip.AddrPort) error {
|
||||
m.mu.Lock()
|
||||
server := m.server
|
||||
m.mu.Unlock()
|
||||
|
||||
if server == nil {
|
||||
return errors.New("receiver is not running")
|
||||
}
|
||||
return server.AddListener(ctx, addr)
|
||||
}
|
||||
|
||||
// StopReceiver shuts the receiving server down and drops the tunnel dialer.
|
||||
func (m *Manager) StopReceiver() error {
|
||||
m.mu.Lock()
|
||||
server := m.server
|
||||
m.server = nil
|
||||
m.dial = nil
|
||||
m.mu.Unlock()
|
||||
|
||||
if server == nil {
|
||||
return nil
|
||||
}
|
||||
return server.Stop()
|
||||
}
|
||||
|
||||
// SetTunnel gives the manager the tunnel dialer and the local sender name.
|
||||
func (m *Manager) SetTunnel(dial DialFunc, senderName string) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.dial = dial
|
||||
m.senderName = senderName
|
||||
}
|
||||
|
||||
// Close stops the receiver and aborts every outgoing transfer.
|
||||
func (m *Manager) Close() error {
|
||||
err := m.StopReceiver()
|
||||
|
||||
m.mu.Lock()
|
||||
for _, h := range m.sends {
|
||||
h.cancel()
|
||||
}
|
||||
m.mu.Unlock()
|
||||
|
||||
m.sendWg.Wait()
|
||||
return err
|
||||
}
|
||||
|
||||
// Send starts an asynchronous transfer and returns its local transfer ID.
|
||||
func (m *Manager) Send(peer PeerKey, peerName string, addr netip.Addr, payloads []Payload) (OfferID, error) {
|
||||
if len(payloads) == 0 {
|
||||
return "", fmt.Errorf("%w: no payloads", ErrInvalidOffer)
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
dial, senderName := m.dial, m.senderName
|
||||
m.mu.Unlock()
|
||||
if dial == nil {
|
||||
return "", ErrNotConnected
|
||||
}
|
||||
|
||||
client, err := NewClient(ClientConfig{Dial: dial, SenderName: senderName, OfferTimeout: m.offerTTL})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
id := OfferID(uuid.NewString())
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
handle := &sendHandle{cancel: cancel, ip: addr}
|
||||
|
||||
m.mu.Lock()
|
||||
m.sends[id] = handle
|
||||
m.mu.Unlock()
|
||||
|
||||
transfer := Transfer{
|
||||
ID: id,
|
||||
Direction: DirectionSent,
|
||||
PeerKey: peer,
|
||||
PeerName: peerName,
|
||||
Files: payloadMetas(payloads),
|
||||
State: StatePending,
|
||||
TotalSize: payloadTotal(payloads),
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
m.history.Upsert(transfer)
|
||||
|
||||
m.sendWg.Add(1)
|
||||
go func() {
|
||||
defer m.sendWg.Done()
|
||||
defer cancel()
|
||||
m.runSend(ctx, client, handle, transfer, payloads)
|
||||
|
||||
m.mu.Lock()
|
||||
delete(m.sends, id)
|
||||
m.mu.Unlock()
|
||||
}()
|
||||
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// Cancel aborts a transfer in either direction.
|
||||
func (m *Manager) Cancel(id OfferID) {
|
||||
m.mu.Lock()
|
||||
handle := m.sends[id]
|
||||
server := m.server
|
||||
var remoteAddr netip.AddrPort
|
||||
var remoteID OfferID
|
||||
if handle != nil {
|
||||
remoteAddr, remoteID = handle.addr, handle.remoteID
|
||||
}
|
||||
m.mu.Unlock()
|
||||
|
||||
if handle != nil {
|
||||
handle.cancel()
|
||||
if remoteID != "" && remoteAddr.IsValid() {
|
||||
m.withdrawRemote(remoteAddr, remoteID)
|
||||
}
|
||||
m.finishTransfer(id, StateCancelled, "")
|
||||
return
|
||||
}
|
||||
|
||||
if server != nil {
|
||||
if offer, ok := server.Offers().Decide(id, DecisionDeclined); ok {
|
||||
server.Spool().Remove(offer.ID)
|
||||
}
|
||||
}
|
||||
m.finishTransfer(id, StateCancelled, "")
|
||||
}
|
||||
|
||||
// Accept releases a pending incoming offer for upload.
|
||||
func (m *Manager) Accept(id OfferID) error {
|
||||
m.mu.Lock()
|
||||
server := m.server
|
||||
m.mu.Unlock()
|
||||
if server == nil {
|
||||
return ErrNotConnected
|
||||
}
|
||||
|
||||
offer, ok := server.Offers().Decide(id, DecisionAccepted)
|
||||
if !ok {
|
||||
return ErrOfferNotFound
|
||||
}
|
||||
|
||||
if offer.State == StateCompleted {
|
||||
m.OnCompleted(offer)
|
||||
return nil
|
||||
}
|
||||
|
||||
m.history.SetProgress(id, 0)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Decline refuses a pending incoming offer.
|
||||
func (m *Manager) Decline(id OfferID) error {
|
||||
m.mu.Lock()
|
||||
server := m.server
|
||||
m.mu.Unlock()
|
||||
if server == nil {
|
||||
return ErrNotConnected
|
||||
}
|
||||
|
||||
offer, ok := server.Offers().Decide(id, DecisionDeclined)
|
||||
if !ok {
|
||||
return ErrOfferNotFound
|
||||
}
|
||||
|
||||
server.Spool().Remove(offer.ID)
|
||||
m.finishTransfer(id, StateDeclined, "")
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetSenderRule records a per-sender exception.
|
||||
func (m *Manager) SetSenderRule(peer PeerKey, rule SenderRule) error {
|
||||
if err := m.policy.SetSenderRule(peer, rule); err != nil {
|
||||
return err
|
||||
}
|
||||
if rule != SenderRuleBlock {
|
||||
return nil
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
server := m.server
|
||||
m.mu.Unlock()
|
||||
if server == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
for _, offer := range server.Offers().List() {
|
||||
if offer.Sender != peer || offer.Decision != DecisionPending {
|
||||
continue
|
||||
}
|
||||
if declined, ok := server.Offers().Decide(offer.ID, DecisionDeclined); ok {
|
||||
server.Spool().Remove(declined.ID)
|
||||
m.finishTransfer(declined.ID, StateDeclined, "")
|
||||
m.emit(EventWithdrawn, m.transferOf(declined.ID))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) runSend(ctx context.Context, client *Client, handle *sendHandle, transfer Transfer, payloads []Payload) {
|
||||
addr, remoteID, decision, err := m.offerWithPortRetry(ctx, client, handle, transfer.PeerKey, payloads)
|
||||
if err != nil {
|
||||
m.failSend(ctx, transfer.ID, err)
|
||||
return
|
||||
}
|
||||
|
||||
decision, err = client.AwaitDecision(ctx, addr, remoteID, decision)
|
||||
if err != nil {
|
||||
m.failSend(ctx, transfer.ID, err)
|
||||
return
|
||||
}
|
||||
if err := decisionError(decision); err != nil {
|
||||
m.failSend(ctx, transfer.ID, err)
|
||||
return
|
||||
}
|
||||
|
||||
m.history.SetProgress(transfer.ID, 0)
|
||||
|
||||
completed := make([]int64, len(payloads))
|
||||
progress := func(index int, sent, _ int64) {
|
||||
completed[index] = sent
|
||||
var total int64
|
||||
for _, n := range completed {
|
||||
total += n
|
||||
}
|
||||
m.history.SetProgress(transfer.ID, total)
|
||||
}
|
||||
|
||||
if err := client.Upload(ctx, addr, remoteID, payloads, progress); err != nil {
|
||||
m.failSend(ctx, transfer.ID, err)
|
||||
return
|
||||
}
|
||||
|
||||
m.history.SetProgress(transfer.ID, transfer.TotalSize)
|
||||
m.finishTransfer(transfer.ID, StateCompleted, "")
|
||||
m.emit(EventCompleted, m.transferOf(transfer.ID))
|
||||
}
|
||||
|
||||
// offerWithPortRetry places the offer on the last advertised port, falling back to
|
||||
// the default. When the attempt fails on the transport, it waits out one signal
|
||||
// message that may carry the receiver's actual port and retries there once. A port
|
||||
// learned mid-attempt aborts the attempt immediately instead of letting it hang.
|
||||
func (m *Manager) offerWithPortRetry(ctx context.Context, client *Client, handle *sendHandle, key PeerKey, payloads []Payload) (netip.AddrPort, OfferID, Decision, error) {
|
||||
used := m.ports.Port(key)
|
||||
addr := netip.AddrPortFrom(handle.ip, effectivePort(used))
|
||||
|
||||
remoteID, decision, err := m.offerWatchingPorts(ctx, client, key, used, addr, payloads)
|
||||
if err == nil {
|
||||
m.storeRemote(handle, addr, remoteID)
|
||||
return addr, remoteID, decision, nil
|
||||
}
|
||||
if ctx.Err() != nil || !transportFailure(err) {
|
||||
return addr, remoteID, decision, err
|
||||
}
|
||||
|
||||
graceCtx, cancel := context.WithTimeout(ctx, portSignalGrace)
|
||||
port, changed := m.ports.Await(graceCtx, key, used)
|
||||
cancel()
|
||||
if !changed {
|
||||
return addr, remoteID, decision, err
|
||||
}
|
||||
|
||||
addr = netip.AddrPortFrom(handle.ip, effectivePort(port))
|
||||
remoteID, decision, err = client.Offer(ctx, addr, payloads)
|
||||
if err != nil {
|
||||
return addr, remoteID, decision, err
|
||||
}
|
||||
m.storeRemote(handle, addr, remoteID)
|
||||
return addr, remoteID, decision, nil
|
||||
}
|
||||
|
||||
// offerWatchingPorts runs the offer while watching for a port advertisement that
|
||||
// differs from the one in use; such an advertisement aborts the in-flight attempt.
|
||||
func (m *Manager) offerWatchingPorts(ctx context.Context, client *Client, key PeerKey, used uint16, addr netip.AddrPort, payloads []Payload) (OfferID, Decision, error) {
|
||||
watchCtx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
|
||||
go func() {
|
||||
if _, changed := m.ports.Await(watchCtx, key, used); changed {
|
||||
cancel()
|
||||
}
|
||||
}()
|
||||
|
||||
return client.Offer(watchCtx, addr, payloads)
|
||||
}
|
||||
|
||||
func (m *Manager) storeRemote(handle *sendHandle, addr netip.AddrPort, remoteID OfferID) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
handle.addr = addr
|
||||
handle.remoteID = remoteID
|
||||
}
|
||||
|
||||
func (m *Manager) failSend(ctx context.Context, id OfferID, err error) {
|
||||
if ctx.Err() != nil {
|
||||
m.finishTransfer(id, StateCancelled, "")
|
||||
return
|
||||
}
|
||||
|
||||
state := StateFailed
|
||||
switch {
|
||||
case errors.Is(err, ErrDeclined):
|
||||
state = StateDeclined
|
||||
case errors.Is(err, ErrExpired):
|
||||
state = StateExpired
|
||||
}
|
||||
|
||||
message := ""
|
||||
reason := ReasonNone
|
||||
if state == StateFailed {
|
||||
message = err.Error()
|
||||
if transportFailure(err) {
|
||||
reason = ReasonUnreachable
|
||||
}
|
||||
}
|
||||
m.finishTransferReason(id, state, message, reason)
|
||||
m.emit(EventFailed, m.transferOf(id))
|
||||
}
|
||||
|
||||
func (m *Manager) withdrawRemote(addr netip.AddrPort, remoteID OfferID) {
|
||||
m.mu.Lock()
|
||||
dial, senderName := m.dial, m.senderName
|
||||
m.mu.Unlock()
|
||||
if dial == nil {
|
||||
return
|
||||
}
|
||||
|
||||
client, err := NewClient(ClientConfig{Dial: dial, SenderName: senderName})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
if err := client.Cancel(ctx, addr, remoteID); err != nil {
|
||||
log.Debugf("failed to withdraw file drop offer: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// OnOffer implements Notifier for the receiver server.
|
||||
func (m *Manager) OnOffer(offer Offer) {
|
||||
transfer := Transfer{
|
||||
ID: offer.ID,
|
||||
Direction: DirectionReceived,
|
||||
PeerKey: offer.Sender,
|
||||
PeerName: offer.SenderName,
|
||||
Files: offer.Files,
|
||||
State: offer.State,
|
||||
TotalSize: offer.TotalSize(),
|
||||
CreatedAt: offer.CreatedAt,
|
||||
}
|
||||
m.history.Upsert(transfer)
|
||||
|
||||
if offer.Decision == DecisionPending {
|
||||
m.emit(EventOffer, transfer)
|
||||
}
|
||||
if offer.Decision == DecisionAccepted && offer.State == StateCompleted {
|
||||
m.OnCompleted(offer)
|
||||
}
|
||||
}
|
||||
|
||||
// OnProgress implements Notifier.
|
||||
func (m *Manager) OnProgress(offer Offer, index int, received int64) {
|
||||
var total int64
|
||||
for i, n := range offer.Progress {
|
||||
if i == index {
|
||||
n = received
|
||||
}
|
||||
total += n
|
||||
}
|
||||
m.history.SetProgress(offer.ID, total)
|
||||
}
|
||||
|
||||
// OnCompleted implements Notifier.
|
||||
func (m *Manager) OnCompleted(offer Offer) {
|
||||
m.mu.Lock()
|
||||
server := m.server
|
||||
m.mu.Unlock()
|
||||
if server == nil {
|
||||
return
|
||||
}
|
||||
|
||||
transfer, ok := m.history.Get(offer.ID)
|
||||
if !ok || transfer.State == StateCompleted {
|
||||
return
|
||||
}
|
||||
|
||||
delivered, err := deliver(server.Spool(), offer, m.policy.DestinationDir())
|
||||
if err != nil {
|
||||
log.Errorf("failed to deliver file drop payloads: %v", err)
|
||||
m.finishTransfer(offer.ID, StateFailed, err.Error())
|
||||
m.emit(EventFailed, m.transferOf(offer.ID))
|
||||
return
|
||||
}
|
||||
|
||||
transfer.State = StateCompleted
|
||||
transfer.Transferred = transfer.TotalSize
|
||||
transfer.DeliveredPaths = delivered
|
||||
transfer.Error = ""
|
||||
m.history.Upsert(transfer)
|
||||
m.emit(EventCompleted, transfer)
|
||||
}
|
||||
|
||||
// OnFailed implements Notifier.
|
||||
func (m *Manager) OnFailed(offer Offer, err error) {
|
||||
if errors.Is(err, ErrExpired) {
|
||||
m.finishTransfer(offer.ID, StateExpired, "")
|
||||
m.emit(EventWithdrawn, m.transferOf(offer.ID))
|
||||
return
|
||||
}
|
||||
m.finishTransfer(offer.ID, StateFailed, err.Error())
|
||||
m.emit(EventFailed, m.transferOf(offer.ID))
|
||||
}
|
||||
|
||||
// OnWithdrawn implements Notifier: the sender cancelled, so the consent prompt goes away.
|
||||
func (m *Manager) OnWithdrawn(offer Offer) {
|
||||
m.finishTransfer(offer.ID, StateCancelled, "")
|
||||
m.emit(EventWithdrawn, m.transferOf(offer.ID))
|
||||
}
|
||||
|
||||
func (m *Manager) finishTransfer(id OfferID, state State, message string) {
|
||||
m.finishTransferReason(id, state, message, ReasonNone)
|
||||
}
|
||||
|
||||
func (m *Manager) finishTransferReason(id OfferID, state State, message string, reason FailureReason) {
|
||||
transfer, ok := m.history.Get(id)
|
||||
if !ok || transfer.terminal() {
|
||||
return
|
||||
}
|
||||
transfer.State = state
|
||||
transfer.Error = message
|
||||
transfer.Reason = reason
|
||||
m.history.Upsert(transfer)
|
||||
}
|
||||
|
||||
func (m *Manager) transferOf(id OfferID) Transfer {
|
||||
t, _ := m.history.Get(id)
|
||||
return t
|
||||
}
|
||||
|
||||
func (m *Manager) emit(kind EventKind, transfer Transfer) {
|
||||
if m.events != nil && transfer.ID != "" {
|
||||
m.events(kind, transfer)
|
||||
}
|
||||
}
|
||||
|
||||
func payloadMetas(payloads []Payload) []FileMeta {
|
||||
metas := make([]FileMeta, len(payloads))
|
||||
for i, p := range payloads {
|
||||
metas[i] = p.Meta
|
||||
}
|
||||
return metas
|
||||
}
|
||||
|
||||
func payloadTotal(payloads []Payload) int64 {
|
||||
var total int64
|
||||
for _, p := range payloads {
|
||||
total += p.Meta.Size
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
func effectivePort(advertised uint16) uint16 {
|
||||
if advertised == 0 {
|
||||
return Port
|
||||
}
|
||||
return advertised
|
||||
}
|
||||
|
||||
// transportFailure reports whether the offer never reached the receiver; any HTTP
|
||||
// response, refusal included, proves the port right and is not retried elsewhere.
|
||||
func transportFailure(err error) bool {
|
||||
var urlErr *url.Error
|
||||
return errors.As(err, &urlErr)
|
||||
}
|
||||
282
client/internal/filedrop/offer.go
Normal file
282
client/internal/filedrop/offer.go
Normal file
@@ -0,0 +1,282 @@
|
||||
package filedrop
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// Offer is one incoming transfer as tracked by the receiver.
|
||||
type Offer struct {
|
||||
ID OfferID
|
||||
Sender PeerKey
|
||||
SenderName string
|
||||
Files []FileMeta
|
||||
Decision Decision
|
||||
State State
|
||||
CreatedAt time.Time
|
||||
ExpiresAt time.Time
|
||||
Progress []int64
|
||||
}
|
||||
|
||||
type offerEntry struct {
|
||||
offer Offer
|
||||
decided chan struct{}
|
||||
}
|
||||
|
||||
// OfferStore tracks incoming offers and their decisions.
|
||||
type OfferStore struct {
|
||||
mu sync.RWMutex
|
||||
offers map[OfferID]*offerEntry
|
||||
ttl time.Duration
|
||||
newID func() OfferID
|
||||
nowFunc func() time.Time
|
||||
}
|
||||
|
||||
// NewOfferStore returns an empty store using ttl as the decision deadline.
|
||||
func NewOfferStore(ttl time.Duration) *OfferStore {
|
||||
if ttl <= 0 {
|
||||
ttl = DefaultOfferTTL
|
||||
}
|
||||
return &OfferStore{
|
||||
offers: make(map[OfferID]*offerEntry),
|
||||
ttl: ttl,
|
||||
newID: func() OfferID { return OfferID(uuid.NewString()) },
|
||||
nowFunc: time.Now,
|
||||
}
|
||||
}
|
||||
|
||||
// Add registers a new offer with the given initial decision and returns its snapshot.
|
||||
func (s *OfferStore) Add(sender PeerKey, senderName string, files []FileMeta, decision Decision) Offer {
|
||||
now := s.nowFunc()
|
||||
|
||||
entry := &offerEntry{
|
||||
offer: Offer{
|
||||
ID: s.newID(),
|
||||
Sender: sender,
|
||||
SenderName: senderName,
|
||||
Files: files,
|
||||
Decision: decision,
|
||||
State: stateForDecision(decision),
|
||||
CreatedAt: now,
|
||||
ExpiresAt: now.Add(s.ttl),
|
||||
Progress: make([]int64, len(files)),
|
||||
},
|
||||
decided: make(chan struct{}),
|
||||
}
|
||||
if decision != DecisionPending {
|
||||
close(entry.decided)
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
s.offers[entry.offer.ID] = entry
|
||||
s.mu.Unlock()
|
||||
|
||||
return entry.offer.clone()
|
||||
}
|
||||
|
||||
// Get returns a snapshot of one offer belonging to sender.
|
||||
func (s *OfferStore) Get(sender PeerKey, id OfferID) (Offer, bool) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
entry, ok := s.offers[id]
|
||||
if !ok || entry.offer.Sender != sender {
|
||||
return Offer{}, false
|
||||
}
|
||||
return entry.offer.clone(), true
|
||||
}
|
||||
|
||||
// List returns snapshots of every tracked offer.
|
||||
func (s *OfferStore) List() []Offer {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
offers := make([]Offer, 0, len(s.offers))
|
||||
for _, entry := range s.offers {
|
||||
offers = append(offers, entry.offer.clone())
|
||||
}
|
||||
return offers
|
||||
}
|
||||
|
||||
// Decide records the receiver's answer; a made decision is final.
|
||||
func (s *OfferStore) Decide(id OfferID, decision Decision) (Offer, bool) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
entry, ok := s.offers[id]
|
||||
if !ok || entry.offer.Decision != DecisionPending {
|
||||
return Offer{}, false
|
||||
}
|
||||
|
||||
entry.offer.Decision = decision
|
||||
entry.offer.State = stateForDecision(decision)
|
||||
if decision == DecisionAccepted && entry.offer.awaitsNoUpload() {
|
||||
entry.offer.State = StateCompleted
|
||||
}
|
||||
close(entry.decided)
|
||||
|
||||
return entry.offer.clone(), true
|
||||
}
|
||||
|
||||
// Await blocks until a decision, expiry, or ctx cancellation.
|
||||
func (s *OfferStore) Await(ctx context.Context, sender PeerKey, id OfferID) (Offer, error) {
|
||||
s.mu.RLock()
|
||||
entry, ok := s.offers[id]
|
||||
if ok && entry.offer.Sender != sender {
|
||||
ok = false
|
||||
}
|
||||
var decided chan struct{}
|
||||
var expiresAt time.Time
|
||||
if ok {
|
||||
decided = entry.decided
|
||||
expiresAt = entry.offer.ExpiresAt
|
||||
}
|
||||
s.mu.RUnlock()
|
||||
|
||||
if !ok {
|
||||
return Offer{}, ErrOfferNotFound
|
||||
}
|
||||
|
||||
timer := time.NewTimer(time.Until(expiresAt))
|
||||
defer timer.Stop()
|
||||
|
||||
select {
|
||||
case <-decided:
|
||||
case <-timer.C:
|
||||
s.Decide(id, DecisionExpired)
|
||||
case <-ctx.Done():
|
||||
offer, _ := s.Get(sender, id)
|
||||
return offer, ctx.Err()
|
||||
}
|
||||
|
||||
offer, ok := s.Get(sender, id)
|
||||
if !ok {
|
||||
return Offer{}, ErrOfferNotFound
|
||||
}
|
||||
return offer, nil
|
||||
}
|
||||
|
||||
// SetProgress records the staged byte count for one file of an offer.
|
||||
func (s *OfferStore) SetProgress(id OfferID, index int, received int64) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
entry, ok := s.offers[id]
|
||||
if !ok || index < 0 || index >= len(entry.offer.Progress) {
|
||||
return
|
||||
}
|
||||
entry.offer.Progress[index] = received
|
||||
if entry.offer.State == StatePending {
|
||||
entry.offer.State = StateTransferring
|
||||
}
|
||||
}
|
||||
|
||||
// SetState overrides the transfer state, for completion and failure reporting.
|
||||
func (s *OfferStore) SetState(id OfferID, state State) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if entry, ok := s.offers[id]; ok {
|
||||
entry.offer.State = state
|
||||
}
|
||||
}
|
||||
|
||||
// Remove drops an offer from the store.
|
||||
func (s *OfferStore) Remove(id OfferID) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
delete(s.offers, id)
|
||||
}
|
||||
|
||||
// ExpireOverdue marks every pending offer past its deadline as expired and returns them.
|
||||
func (s *OfferStore) ExpireOverdue() []Offer {
|
||||
now := s.nowFunc()
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
var expired []Offer
|
||||
for _, entry := range s.offers {
|
||||
if entry.offer.Decision != DecisionPending || now.Before(entry.offer.ExpiresAt) {
|
||||
continue
|
||||
}
|
||||
entry.offer.Decision = DecisionExpired
|
||||
entry.offer.State = StateExpired
|
||||
close(entry.decided)
|
||||
expired = append(expired, entry.offer.clone())
|
||||
}
|
||||
return expired
|
||||
}
|
||||
|
||||
// Complete marks an offer completed once every file reached its announced size.
|
||||
func (s *OfferStore) Complete(id OfferID) (Offer, bool) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
entry, ok := s.offers[id]
|
||||
if !ok || entry.offer.State == StateCompleted {
|
||||
return Offer{}, false
|
||||
}
|
||||
|
||||
if !entry.offer.fullyStaged() {
|
||||
return Offer{}, false
|
||||
}
|
||||
|
||||
entry.offer.State = StateCompleted
|
||||
return entry.offer.clone(), true
|
||||
}
|
||||
|
||||
func (o Offer) awaitsNoUpload() bool {
|
||||
for _, f := range o.Files {
|
||||
if f.Kind != KindText {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (o Offer) fullyStaged() bool {
|
||||
for i, f := range o.Files {
|
||||
if f.Kind == KindText {
|
||||
continue
|
||||
}
|
||||
if i >= len(o.Progress) || o.Progress[i] < f.Size {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (o Offer) clone() Offer {
|
||||
c := o
|
||||
c.Files = make([]FileMeta, len(o.Files))
|
||||
copy(c.Files, o.Files)
|
||||
c.Progress = make([]int64, len(o.Progress))
|
||||
copy(c.Progress, o.Progress)
|
||||
return c
|
||||
}
|
||||
|
||||
// TotalSize is the announced byte count across every file of the offer.
|
||||
func (o Offer) TotalSize() int64 {
|
||||
var total int64
|
||||
for _, f := range o.Files {
|
||||
total += f.Size
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
func stateForDecision(d Decision) State {
|
||||
switch d {
|
||||
case DecisionAccepted:
|
||||
return StateTransferring
|
||||
case DecisionDeclined:
|
||||
return StateDeclined
|
||||
case DecisionExpired:
|
||||
return StateExpired
|
||||
default:
|
||||
return StatePending
|
||||
}
|
||||
}
|
||||
206
client/internal/filedrop/policy.go
Normal file
206
client/internal/filedrop/policy.go
Normal file
@@ -0,0 +1,206 @@
|
||||
package filedrop
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
||||
)
|
||||
|
||||
const (
|
||||
SenderRuleDefault SenderRule = iota
|
||||
SenderRuleAlwaysAccept
|
||||
SenderRuleBlock
|
||||
)
|
||||
|
||||
// SenderRule is a per-sender override on top of the base mode.
|
||||
type SenderRule uint8
|
||||
|
||||
// Policy is the device-local receiving policy of one profile. An empty
|
||||
// DestinationDir means the platform's default download directory.
|
||||
type Policy struct {
|
||||
Mode Mode `json:"mode"`
|
||||
Senders map[PeerKey]SenderRule `json:"senders,omitempty"`
|
||||
DestinationDir string `json:"destinationDir,omitempty"`
|
||||
}
|
||||
|
||||
// PolicyStore holds the receiving policy of one profile and evaluates it per sender.
|
||||
type PolicyStore struct {
|
||||
mu sync.RWMutex
|
||||
profile profilemanager.ID
|
||||
policy Policy
|
||||
store Store
|
||||
}
|
||||
|
||||
// NewPolicyStore returns an in-memory store seeded with the default policy.
|
||||
func NewPolicyStore(profile profilemanager.ID) *PolicyStore {
|
||||
return &PolicyStore{profile: profile, policy: DefaultPolicy()}
|
||||
}
|
||||
|
||||
// LoadPolicyStore builds a store from the persisted policy of one profile.
|
||||
func LoadPolicyStore(profile profilemanager.ID, store Store) *PolicyStore {
|
||||
s := &PolicyStore{
|
||||
profile: profile,
|
||||
policy: DefaultPolicy(),
|
||||
store: store,
|
||||
}
|
||||
if store == nil {
|
||||
return s
|
||||
}
|
||||
|
||||
policy := DefaultPolicy()
|
||||
if err := loadSection(store, namespacePolicy, &policy); err != nil {
|
||||
log.Warnf("failed to load file drop policy for profile %s, using defaults: %v", profile, err)
|
||||
return s
|
||||
}
|
||||
if err := policy.validate(); err != nil {
|
||||
log.Warnf("stored file drop policy for profile %s is invalid, using defaults: %v", profile, err)
|
||||
return s
|
||||
}
|
||||
|
||||
s.policy = policy.normalized()
|
||||
return s
|
||||
}
|
||||
|
||||
// String implements fmt.Stringer.
|
||||
func (r SenderRule) String() string {
|
||||
switch r {
|
||||
case SenderRuleDefault:
|
||||
return "default"
|
||||
case SenderRuleAlwaysAccept:
|
||||
return "always"
|
||||
case SenderRuleBlock:
|
||||
return "block"
|
||||
default:
|
||||
return fmt.Sprintf("unknown(%d)", uint8(r))
|
||||
}
|
||||
}
|
||||
|
||||
func (p Policy) validate() error {
|
||||
if !p.Mode.valid() {
|
||||
return fmt.Errorf("invalid mode %s", p.Mode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p Policy) normalized() Policy {
|
||||
c := p.clone()
|
||||
if c.Senders == nil {
|
||||
c.Senders = map[PeerKey]SenderRule{}
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
func (p Policy) clone() Policy {
|
||||
c := p
|
||||
c.Senders = make(map[PeerKey]SenderRule, len(p.Senders))
|
||||
for k, v := range p.Senders {
|
||||
c.Senders[k] = v
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// Profile returns the profile this policy belongs to.
|
||||
func (s *PolicyStore) Profile() profilemanager.ID {
|
||||
return s.profile
|
||||
}
|
||||
|
||||
// Get returns a copy of the current policy.
|
||||
func (s *PolicyStore) Get() Policy {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return s.policy.clone()
|
||||
}
|
||||
|
||||
// Set replaces the policy and persists it.
|
||||
func (s *PolicyStore) Set(p Policy) error {
|
||||
if err := p.validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
s.policy = p.normalized()
|
||||
store, stored := s.store, s.policy.clone()
|
||||
s.mu.Unlock()
|
||||
|
||||
return saveSection(store, namespacePolicy, stored)
|
||||
}
|
||||
|
||||
// SetMode changes the base mode, leaving per-sender rules untouched.
|
||||
func (s *PolicyStore) SetMode(m Mode) error {
|
||||
if !m.valid() {
|
||||
return fmt.Errorf("invalid mode %s", m)
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
s.policy.Mode = m
|
||||
store, stored := s.store, s.policy.clone()
|
||||
s.mu.Unlock()
|
||||
|
||||
return saveSection(store, namespacePolicy, stored)
|
||||
}
|
||||
|
||||
// SetSenderRule sets or clears the override for a single sender.
|
||||
func (s *PolicyStore) SetSenderRule(key PeerKey, rule SenderRule) error {
|
||||
s.mu.Lock()
|
||||
if rule == SenderRuleDefault {
|
||||
delete(s.policy.Senders, key)
|
||||
} else {
|
||||
if s.policy.Senders == nil {
|
||||
s.policy.Senders = map[PeerKey]SenderRule{}
|
||||
}
|
||||
s.policy.Senders[key] = rule
|
||||
}
|
||||
store, stored := s.store, s.policy.clone()
|
||||
s.mu.Unlock()
|
||||
|
||||
return saveSection(store, namespacePolicy, stored)
|
||||
}
|
||||
|
||||
// DestinationDir returns the directory received files are delivered to.
|
||||
func (s *PolicyStore) DestinationDir() string {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return s.policy.DestinationDir
|
||||
}
|
||||
|
||||
// SetDestinationDir persists the delivery directory.
|
||||
func (s *PolicyStore) SetDestinationDir(dir string) error {
|
||||
s.mu.Lock()
|
||||
s.policy.DestinationDir = dir
|
||||
store, stored := s.store, s.policy.clone()
|
||||
s.mu.Unlock()
|
||||
|
||||
return saveSection(store, namespacePolicy, stored)
|
||||
}
|
||||
|
||||
// Evaluate returns the mode that applies to one sender, denying on unknown values.
|
||||
func (s *PolicyStore) Evaluate(key PeerKey) Mode {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
switch s.policy.Senders[key] {
|
||||
case SenderRuleBlock:
|
||||
return ModeOff
|
||||
case SenderRuleAlwaysAccept:
|
||||
return ModeAutoAccept
|
||||
case SenderRuleDefault:
|
||||
default:
|
||||
return ModeOff
|
||||
}
|
||||
|
||||
if !s.policy.Mode.valid() {
|
||||
return ModeOff
|
||||
}
|
||||
return s.policy.Mode
|
||||
}
|
||||
|
||||
// DefaultPolicy asks before accepting anything, so receiving is never silently on.
|
||||
func DefaultPolicy() Policy {
|
||||
return Policy{
|
||||
Mode: ModeAsk,
|
||||
Senders: map[PeerKey]SenderRule{},
|
||||
}
|
||||
}
|
||||
81
client/internal/filedrop/ports.go
Normal file
81
client/internal/filedrop/ports.go
Normal file
@@ -0,0 +1,81 @@
|
||||
package filedrop
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// PortRegistry tracks the file drop listen port each peer advertised over
|
||||
// signaling; 0 means the well-known default. Senders can wait on it to learn a
|
||||
// better port after a failed attempt.
|
||||
type PortRegistry struct {
|
||||
mu sync.Mutex
|
||||
ports map[PeerKey]uint16
|
||||
waits map[PeerKey][]chan uint16
|
||||
}
|
||||
|
||||
// NewPortRegistry returns an empty registry.
|
||||
func NewPortRegistry() *PortRegistry {
|
||||
return &PortRegistry{
|
||||
ports: make(map[PeerKey]uint16),
|
||||
waits: make(map[PeerKey][]chan uint16),
|
||||
}
|
||||
}
|
||||
|
||||
// Set records the port a peer advertised and releases every waiter for it.
|
||||
func (r *PortRegistry) Set(key PeerKey, port uint16) {
|
||||
r.mu.Lock()
|
||||
r.ports[key] = port
|
||||
waiters := r.waits[key]
|
||||
delete(r.waits, key)
|
||||
r.mu.Unlock()
|
||||
|
||||
for _, ch := range waiters {
|
||||
ch <- port
|
||||
}
|
||||
}
|
||||
|
||||
// Port returns the last advertised port for a peer; 0 means default or unknown.
|
||||
func (r *PortRegistry) Port(key PeerKey) uint16 {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
return r.ports[key]
|
||||
}
|
||||
|
||||
// Await returns the peer's port as soon as it differs from used, or after the next
|
||||
// advertisement even when it does not, reporting whether it differs. It returns
|
||||
// immediately when the currently known port already differs.
|
||||
func (r *PortRegistry) Await(ctx context.Context, key PeerKey, used uint16) (uint16, bool) {
|
||||
r.mu.Lock()
|
||||
if port, ok := r.ports[key]; ok && port != used {
|
||||
r.mu.Unlock()
|
||||
return port, true
|
||||
}
|
||||
ch := make(chan uint16, 1)
|
||||
r.waits[key] = append(r.waits[key], ch)
|
||||
r.mu.Unlock()
|
||||
|
||||
select {
|
||||
case port := <-ch:
|
||||
return port, port != used
|
||||
case <-ctx.Done():
|
||||
r.drop(key, ch)
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
|
||||
func (r *PortRegistry) drop(key PeerKey, ch chan uint16) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
waiters := r.waits[key]
|
||||
for i, w := range waiters {
|
||||
if w == ch {
|
||||
r.waits[key] = append(waiters[:i], waiters[i+1:]...)
|
||||
break
|
||||
}
|
||||
}
|
||||
if len(r.waits[key]) == 0 {
|
||||
delete(r.waits, key)
|
||||
}
|
||||
}
|
||||
197
client/internal/filedrop/protocol.go
Normal file
197
client/internal/filedrop/protocol.go
Normal file
@@ -0,0 +1,197 @@
|
||||
package filedrop
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
const Port uint16 = 41421
|
||||
|
||||
// HeaderReceivedBytes carries the receiver's confirmed byte count in a HEAD response.
|
||||
const HeaderReceivedBytes = "Netbird-Received-Bytes"
|
||||
|
||||
// DefaultOfferTTL bounds how long an offer waits for the receiver's decision.
|
||||
const DefaultOfferTTL = 5 * time.Minute
|
||||
|
||||
// MaxOfferFiles bounds the number of items a single offer may announce.
|
||||
const MaxOfferFiles = 512
|
||||
|
||||
// MaxInlineTextSize bounds an inline text snippet, which is held in memory.
|
||||
const MaxInlineTextSize = 64 * 1024
|
||||
|
||||
const maxOfferBodySize = 1 << 20
|
||||
|
||||
const (
|
||||
pathOffers = "/v1/offers"
|
||||
pathOffersSlash = pathOffers + "/"
|
||||
|
||||
segmentFiles = "files"
|
||||
)
|
||||
|
||||
// The decisions an offer can carry. Pending is the only non-final one.
|
||||
const (
|
||||
DecisionPending Decision = iota
|
||||
DecisionAccepted
|
||||
DecisionDeclined
|
||||
DecisionExpired
|
||||
)
|
||||
|
||||
// The receiving modes a profile can be in.
|
||||
const (
|
||||
ModeOff Mode = iota
|
||||
ModeAsk
|
||||
ModeAutoAccept
|
||||
)
|
||||
|
||||
// The payload kinds an offer can announce.
|
||||
const (
|
||||
KindFile Kind = iota
|
||||
KindText
|
||||
)
|
||||
|
||||
// The states a transfer moves through.
|
||||
const (
|
||||
StatePending State = iota
|
||||
StateTransferring
|
||||
StateCompleted
|
||||
StateDeclined
|
||||
StateExpired
|
||||
StateCancelled
|
||||
StateFailed
|
||||
)
|
||||
|
||||
var (
|
||||
ErrOfferNotFound = errors.New("offer not found")
|
||||
ErrRefused = errors.New("offer refused by receiver")
|
||||
ErrDeclined = errors.New("offer declined")
|
||||
ErrExpired = errors.New("offer expired")
|
||||
ErrNotAccepted = errors.New("offer not accepted")
|
||||
ErrUnknownPeer = errors.New("unknown peer")
|
||||
ErrInvalidOffer = errors.New("invalid offer")
|
||||
)
|
||||
|
||||
// OfferID identifies a single transfer offer on the receiving peer.
|
||||
type OfferID string
|
||||
|
||||
// PeerKey is the remote peer's public key, used as the identity for per-sender policy.
|
||||
type PeerKey string
|
||||
|
||||
// Decision is the receiver's answer to an offer.
|
||||
type Decision uint8
|
||||
|
||||
// Mode is the receiver's profile-local policy for incoming offers.
|
||||
type Mode uint8
|
||||
|
||||
// Kind distinguishes payloads that are written to the spool from inline text snippets.
|
||||
type Kind uint8
|
||||
|
||||
// State is the lifecycle state of a transfer, on either side.
|
||||
type State uint8
|
||||
|
||||
// FileMeta describes one payload item announced in an offer.
|
||||
type FileMeta struct {
|
||||
Name string `json:"name"`
|
||||
Size int64 `json:"size"`
|
||||
ContentType string `json:"contentType,omitempty"`
|
||||
Kind Kind `json:"kind,omitempty"`
|
||||
Text string `json:"text,omitempty"`
|
||||
}
|
||||
|
||||
// OfferRequest is the JSON body of POST /v1/offers. It carries metadata only.
|
||||
type OfferRequest struct {
|
||||
SenderName string `json:"senderName,omitempty"`
|
||||
Files []FileMeta `json:"files"`
|
||||
}
|
||||
|
||||
// OfferResponse is returned for an offer and for every poll of its status.
|
||||
type OfferResponse struct {
|
||||
ID OfferID `json:"id"`
|
||||
Decision Decision `json:"decision"`
|
||||
}
|
||||
|
||||
// String implements fmt.Stringer.
|
||||
func (d Decision) String() string {
|
||||
switch d {
|
||||
case DecisionPending:
|
||||
return "pending"
|
||||
case DecisionAccepted:
|
||||
return "accepted"
|
||||
case DecisionDeclined:
|
||||
return "declined"
|
||||
case DecisionExpired:
|
||||
return "expired"
|
||||
default:
|
||||
return fmt.Sprintf("unknown(%d)", uint8(d))
|
||||
}
|
||||
}
|
||||
|
||||
func (d Decision) valid() bool {
|
||||
switch d {
|
||||
case DecisionPending, DecisionAccepted, DecisionDeclined, DecisionExpired:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// String implements fmt.Stringer.
|
||||
func (m Mode) String() string {
|
||||
switch m {
|
||||
case ModeOff:
|
||||
return "off"
|
||||
case ModeAsk:
|
||||
return "ask"
|
||||
case ModeAutoAccept:
|
||||
return "auto"
|
||||
default:
|
||||
return fmt.Sprintf("unknown(%d)", uint8(m))
|
||||
}
|
||||
}
|
||||
|
||||
func (m Mode) valid() bool {
|
||||
switch m {
|
||||
case ModeOff, ModeAsk, ModeAutoAccept:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// String implements fmt.Stringer.
|
||||
func (k Kind) String() string {
|
||||
switch k {
|
||||
case KindFile:
|
||||
return "file"
|
||||
case KindText:
|
||||
return "text"
|
||||
default:
|
||||
return fmt.Sprintf("unknown(%d)", uint8(k))
|
||||
}
|
||||
}
|
||||
|
||||
func (k Kind) valid() bool {
|
||||
return k == KindFile || k == KindText
|
||||
}
|
||||
|
||||
// String implements fmt.Stringer.
|
||||
func (s State) String() string {
|
||||
switch s {
|
||||
case StatePending:
|
||||
return "pending"
|
||||
case StateTransferring:
|
||||
return "transferring"
|
||||
case StateCompleted:
|
||||
return "completed"
|
||||
case StateDeclined:
|
||||
return "declined"
|
||||
case StateExpired:
|
||||
return "expired"
|
||||
case StateCancelled:
|
||||
return "cancelled"
|
||||
case StateFailed:
|
||||
return "failed"
|
||||
default:
|
||||
return fmt.Sprintf("unknown(%d)", uint8(s))
|
||||
}
|
||||
}
|
||||
226
client/internal/filedrop/receiver.go
Normal file
226
client/internal/filedrop/receiver.go
Normal file
@@ -0,0 +1,226 @@
|
||||
package filedrop
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/netip"
|
||||
"time"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// ErrStorage indicates the receiver could not stage payload data locally.
|
||||
var ErrStorage = errors.New("storage failure")
|
||||
|
||||
type senderIdentity struct {
|
||||
key PeerKey
|
||||
name string
|
||||
}
|
||||
|
||||
// receiver implements the transfer protocol independent of any transport. Every
|
||||
// operation takes the already-authenticated sender identity and returns domain
|
||||
// errors for the transport to map.
|
||||
type receiver struct {
|
||||
policy *PolicyStore
|
||||
resolver PeerResolver
|
||||
notifier Notifier
|
||||
offers *OfferStore
|
||||
spool *Spool
|
||||
spoolMaxAge time.Duration
|
||||
}
|
||||
|
||||
func newReceiver(cfg ServerConfig, spool *Spool, maxAge time.Duration) *receiver {
|
||||
return &receiver{
|
||||
policy: cfg.Policy,
|
||||
resolver: cfg.Resolver,
|
||||
notifier: cfg.Notifier,
|
||||
offers: NewOfferStore(cfg.OfferTTL),
|
||||
spool: spool,
|
||||
spoolMaxAge: maxAge,
|
||||
}
|
||||
}
|
||||
|
||||
// identify maps a source overlay address to a known peer, refusing unknown ones.
|
||||
func (r *receiver) identify(addr netip.Addr) (senderIdentity, bool) {
|
||||
key, name, ok := r.resolver.ResolvePeer(addr.Unmap())
|
||||
if !ok {
|
||||
return senderIdentity{}, false
|
||||
}
|
||||
return senderIdentity{key: key, name: name}, true
|
||||
}
|
||||
|
||||
func (r *receiver) submitOffer(sender senderIdentity, req OfferRequest) (Offer, error) {
|
||||
if err := validateOffer(req.Files); err != nil {
|
||||
return Offer{}, fmt.Errorf("%w: %s", ErrInvalidOffer, err)
|
||||
}
|
||||
|
||||
mode := r.policy.Evaluate(sender.key)
|
||||
if mode == ModeOff {
|
||||
return Offer{}, ErrRefused
|
||||
}
|
||||
|
||||
senderName := sender.name
|
||||
if senderName == "" {
|
||||
senderName = req.SenderName
|
||||
}
|
||||
|
||||
decision := DecisionPending
|
||||
if mode == ModeAutoAccept {
|
||||
decision = DecisionAccepted
|
||||
}
|
||||
|
||||
offer := r.offers.Add(sender.key, senderName, req.Files, decision)
|
||||
if err := r.spool.Prepare(offer.ID); err != nil {
|
||||
r.offers.Remove(offer.ID)
|
||||
log.Errorf("prepare spool for offer: %v", err)
|
||||
return Offer{}, fmt.Errorf("%w: prepare spool", ErrStorage)
|
||||
}
|
||||
|
||||
r.notifyOffer(offer)
|
||||
|
||||
if offer.Decision == DecisionAccepted {
|
||||
if completed, done := r.offers.Complete(offer.ID); done {
|
||||
r.notifyCompleted(completed)
|
||||
}
|
||||
}
|
||||
return offer, nil
|
||||
}
|
||||
|
||||
func (r *receiver) awaitDecision(ctx context.Context, sender senderIdentity, id OfferID) (Offer, error) {
|
||||
return r.offers.Await(ctx, sender.key, id)
|
||||
}
|
||||
|
||||
func (r *receiver) withdraw(sender senderIdentity, id OfferID) error {
|
||||
offer, ok := r.offers.Get(sender.key, id)
|
||||
if !ok {
|
||||
return ErrOfferNotFound
|
||||
}
|
||||
|
||||
r.offers.SetState(id, StateCancelled)
|
||||
r.offers.Remove(id)
|
||||
r.spool.Remove(id)
|
||||
|
||||
offer.State = StateCancelled
|
||||
r.notifyWithdrawn(offer)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *receiver) receivedBytes(sender senderIdentity, id OfferID, index int) (int64, error) {
|
||||
offer, ok := r.offers.Get(sender.key, id)
|
||||
if !ok || index >= len(offer.Files) {
|
||||
return 0, ErrOfferNotFound
|
||||
}
|
||||
|
||||
received, err := r.spool.Received(id, index)
|
||||
if err != nil {
|
||||
log.Debugf("probe spool file: %v", err)
|
||||
return 0, fmt.Errorf("%w: read staged size", ErrStorage)
|
||||
}
|
||||
return received, nil
|
||||
}
|
||||
|
||||
func (r *receiver) upload(sender senderIdentity, id OfferID, index int, offset int64, body io.Reader) error {
|
||||
offer, ok := r.offers.Get(sender.key, id)
|
||||
if !ok || index >= len(offer.Files) {
|
||||
return ErrOfferNotFound
|
||||
}
|
||||
if offer.Decision != DecisionAccepted {
|
||||
return ErrNotAccepted
|
||||
}
|
||||
if offer.Files[index].Kind == KindText {
|
||||
return fmt.Errorf("%w: text payloads carry no body", ErrInvalidOffer)
|
||||
}
|
||||
|
||||
size := offer.Files[index].Size
|
||||
if offset < 0 || offset > size {
|
||||
return fmt.Errorf("%w: offset out of range", ErrInvalidOffer)
|
||||
}
|
||||
|
||||
received, err := r.spool.Write(id, index, offset, body, size)
|
||||
r.offers.SetProgress(id, index, received)
|
||||
r.notifyProgress(offer, index, received)
|
||||
|
||||
if err != nil {
|
||||
r.offers.SetState(id, StateFailed)
|
||||
r.notifyFailed(offer, err)
|
||||
log.Debugf("stage payload for offer %s file %d: %v", id, index, err)
|
||||
return fmt.Errorf("%w: stage payload", ErrStorage)
|
||||
}
|
||||
|
||||
if completed, ok := r.offers.Complete(id); ok {
|
||||
r.notifyCompleted(completed)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// expireOverdue reclaims offers past their decision deadline and stale spool data.
|
||||
func (r *receiver) expireOverdue() {
|
||||
for _, offer := range r.offers.ExpireOverdue() {
|
||||
r.spool.Remove(offer.ID)
|
||||
r.notifyFailed(offer, ErrExpired)
|
||||
}
|
||||
r.spool.Cleanup(r.spoolMaxAge, time.Now())
|
||||
}
|
||||
|
||||
func (r *receiver) close() {
|
||||
for _, offer := range r.offers.List() {
|
||||
r.offers.Remove(offer.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func (r *receiver) notifyOffer(offer Offer) {
|
||||
if r.notifier != nil {
|
||||
r.notifier.OnOffer(offer)
|
||||
}
|
||||
}
|
||||
|
||||
func (r *receiver) notifyProgress(offer Offer, index int, received int64) {
|
||||
if r.notifier != nil {
|
||||
r.notifier.OnProgress(offer, index, received)
|
||||
}
|
||||
}
|
||||
|
||||
func (r *receiver) notifyCompleted(offer Offer) {
|
||||
if r.notifier != nil {
|
||||
r.notifier.OnCompleted(offer)
|
||||
}
|
||||
}
|
||||
|
||||
func (r *receiver) notifyFailed(offer Offer, err error) {
|
||||
if r.notifier != nil {
|
||||
r.notifier.OnFailed(offer, err)
|
||||
}
|
||||
}
|
||||
|
||||
func (r *receiver) notifyWithdrawn(offer Offer) {
|
||||
if r.notifier != nil {
|
||||
r.notifier.OnWithdrawn(offer)
|
||||
}
|
||||
}
|
||||
|
||||
func validateOffer(files []FileMeta) error {
|
||||
if len(files) == 0 {
|
||||
return fmt.Errorf("offer announces no files")
|
||||
}
|
||||
if len(files) > MaxOfferFiles {
|
||||
return fmt.Errorf("offer announces more than %d files", MaxOfferFiles)
|
||||
}
|
||||
|
||||
for _, f := range files {
|
||||
if !f.Kind.valid() {
|
||||
return fmt.Errorf("unknown payload kind %s", f.Kind)
|
||||
}
|
||||
if f.Kind == KindText {
|
||||
if len(f.Text) > MaxInlineTextSize {
|
||||
return fmt.Errorf("inline text exceeds %d bytes", MaxInlineTextSize)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if f.Size < 0 {
|
||||
return fmt.Errorf("negative file size")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
262
client/internal/filedrop/server.go
Normal file
262
client/internal/filedrop/server.go
Normal file
@@ -0,0 +1,262 @@
|
||||
package filedrop
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
"golang.zx2c4.com/wireguard/tun/netstack"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultSpoolMaxAge = 24 * time.Hour
|
||||
janitorInterval = 10 * time.Minute
|
||||
readHeaderTimeout = 30 * time.Second
|
||||
idleTimeout = 5 * time.Minute
|
||||
)
|
||||
|
||||
// PeerResolver maps the source overlay address of a connection to the peer that owns it.
|
||||
type PeerResolver interface {
|
||||
ResolvePeer(addr netip.Addr) (key PeerKey, name string, ok bool)
|
||||
}
|
||||
|
||||
// Notifier receives receiver-side transfer events for the platform layer to surface.
|
||||
type Notifier interface {
|
||||
OnOffer(offer Offer)
|
||||
OnProgress(offer Offer, index int, received int64)
|
||||
OnCompleted(offer Offer)
|
||||
OnFailed(offer Offer, err error)
|
||||
OnWithdrawn(offer Offer)
|
||||
}
|
||||
|
||||
// ServerConfig configures the receiving side.
|
||||
type ServerConfig struct {
|
||||
SpoolDir string
|
||||
Policy *PolicyStore
|
||||
Resolver PeerResolver
|
||||
Notifier Notifier
|
||||
OfferTTL time.Duration
|
||||
SpoolMaxAge time.Duration
|
||||
}
|
||||
|
||||
// Server serves the receiver over HTTP on the overlay address and owns the
|
||||
// listener and janitor lifecycle; the protocol logic itself lives in receiver.
|
||||
type Server struct {
|
||||
mu sync.RWMutex
|
||||
httpServer *http.Server
|
||||
listener net.Listener
|
||||
extraListeners []net.Listener
|
||||
netstackNet *netstack.Net
|
||||
|
||||
recv *receiver
|
||||
boundPort uint16
|
||||
|
||||
janitorStop context.CancelFunc
|
||||
janitorDone chan struct{}
|
||||
}
|
||||
|
||||
// NewServer builds a receiving server. It does not start listening.
|
||||
func NewServer(cfg ServerConfig) (*Server, error) {
|
||||
if cfg.Resolver == nil {
|
||||
return nil, errors.New("peer resolver is required")
|
||||
}
|
||||
if cfg.Policy == nil {
|
||||
return nil, errors.New("receiving policy is required")
|
||||
}
|
||||
|
||||
spool, err := NewSpool(cfg.SpoolDir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create spool: %w", err)
|
||||
}
|
||||
|
||||
maxAge := cfg.SpoolMaxAge
|
||||
if maxAge <= 0 {
|
||||
maxAge = defaultSpoolMaxAge
|
||||
}
|
||||
|
||||
return &Server{recv: newReceiver(cfg, spool, maxAge)}, nil
|
||||
}
|
||||
|
||||
// SetNetstackNet routes listeners through the gVisor netstack instead of host sockets.
|
||||
func (s *Server) SetNetstackNet(n *netstack.Net) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.netstackNet = n
|
||||
}
|
||||
|
||||
// Offers returns the offer store, for the platform layer to accept, decline, and list.
|
||||
func (s *Server) Offers() *OfferStore {
|
||||
return s.recv.offers
|
||||
}
|
||||
|
||||
// Spool returns the staging area, so the platform layer can deliver completed payloads.
|
||||
func (s *Server) Spool() *Spool {
|
||||
return s.recv.spool
|
||||
}
|
||||
|
||||
// Policy returns the active profile's receiving policy store.
|
||||
func (s *Server) Policy() *PolicyStore {
|
||||
return s.recv.policy
|
||||
}
|
||||
|
||||
// BoundPort returns the port the server actually listens on, 0 when stopped.
|
||||
func (s *Server) BoundPort() uint16 {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return s.boundPort
|
||||
}
|
||||
|
||||
// Start binds the service to addr and serves until Stop.
|
||||
func (s *Server) Start(ctx context.Context, addr netip.AddrPort) error {
|
||||
s.mu.Lock()
|
||||
if s.httpServer != nil {
|
||||
s.mu.Unlock()
|
||||
return errors.New("file drop server is already running")
|
||||
}
|
||||
|
||||
ln, desc, err := s.createListener(ctx, addr)
|
||||
if err != nil && addr.Port() != 0 {
|
||||
log.Warnf("file drop port %d is unavailable, falling back to a dynamic port: %v", addr.Port(), err)
|
||||
ln, desc, err = s.createListener(ctx, netip.AddrPortFrom(addr.Addr(), 0))
|
||||
}
|
||||
if err != nil {
|
||||
s.mu.Unlock()
|
||||
return fmt.Errorf("create listener: %w", err)
|
||||
}
|
||||
|
||||
transport := &httpTransport{recv: s.recv}
|
||||
httpServer := &http.Server{
|
||||
Handler: transport.routes(),
|
||||
ReadHeaderTimeout: readHeaderTimeout,
|
||||
IdleTimeout: idleTimeout,
|
||||
}
|
||||
|
||||
janitorCtx, cancel := context.WithCancel(context.Background())
|
||||
done := make(chan struct{})
|
||||
|
||||
s.listener = ln
|
||||
s.httpServer = httpServer
|
||||
s.boundPort = listenerPort(ln, addr.Port())
|
||||
s.janitorStop = cancel
|
||||
s.janitorDone = done
|
||||
s.mu.Unlock()
|
||||
|
||||
go s.runJanitor(janitorCtx, done)
|
||||
go s.serve(httpServer, ln, desc)
|
||||
|
||||
log.Infof("file drop server started on %s", desc)
|
||||
return nil
|
||||
}
|
||||
|
||||
// AddListener serves the running service on an additional address, such as IPv6.
|
||||
func (s *Server) AddListener(ctx context.Context, addr netip.AddrPort) error {
|
||||
s.mu.Lock()
|
||||
httpServer := s.httpServer
|
||||
if httpServer == nil {
|
||||
s.mu.Unlock()
|
||||
return errors.New("file drop server is not running")
|
||||
}
|
||||
|
||||
ln, desc, err := s.createListener(ctx, addr)
|
||||
if err != nil {
|
||||
s.mu.Unlock()
|
||||
return fmt.Errorf("create listener: %w", err)
|
||||
}
|
||||
s.extraListeners = append(s.extraListeners, ln)
|
||||
s.mu.Unlock()
|
||||
|
||||
go s.serve(httpServer, ln, desc)
|
||||
|
||||
log.Infof("file drop server also listening on %s", desc)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Stop shuts the service down and releases the offers it was tracking. It is idempotent.
|
||||
func (s *Server) Stop() error {
|
||||
s.mu.Lock()
|
||||
httpServer := s.httpServer
|
||||
if httpServer == nil {
|
||||
s.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
s.httpServer = nil
|
||||
s.listener = nil
|
||||
s.boundPort = 0
|
||||
extra := s.extraListeners
|
||||
s.extraListeners = nil
|
||||
stopJanitor, janitorDone := s.janitorStop, s.janitorDone
|
||||
s.janitorStop, s.janitorDone = nil, nil
|
||||
s.mu.Unlock()
|
||||
|
||||
if stopJanitor != nil {
|
||||
stopJanitor()
|
||||
<-janitorDone
|
||||
}
|
||||
|
||||
err := httpServer.Close()
|
||||
|
||||
for _, ln := range extra {
|
||||
if cerr := ln.Close(); cerr != nil {
|
||||
log.Debugf("close extra file drop listener: %v", cerr)
|
||||
}
|
||||
}
|
||||
|
||||
s.recv.close()
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("close: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) serve(httpServer *http.Server, ln net.Listener, desc string) {
|
||||
if err := httpServer.Serve(ln); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
log.Errorf("file drop server error on %s: %v", desc, err)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) createListener(ctx context.Context, addr netip.AddrPort) (net.Listener, string, error) {
|
||||
if s.netstackNet != nil {
|
||||
ln, err := s.netstackNet.ListenTCPAddrPort(addr)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("listen on netstack: %w", err)
|
||||
}
|
||||
return ln, fmt.Sprintf("netstack %s", addr), nil
|
||||
}
|
||||
|
||||
var lc net.ListenConfig
|
||||
ln, err := lc.Listen(ctx, "tcp", net.TCPAddrFromAddrPort(addr).String())
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("listen: %w", err)
|
||||
}
|
||||
return ln, addr.String(), nil
|
||||
}
|
||||
|
||||
func (s *Server) runJanitor(ctx context.Context, done chan struct{}) {
|
||||
defer close(done)
|
||||
|
||||
ticker := time.NewTicker(janitorInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
s.recv.expireOverdue()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func listenerPort(ln net.Listener, requested uint16) uint16 {
|
||||
if tcpAddr, ok := ln.Addr().(*net.TCPAddr); ok {
|
||||
return uint16(tcpAddr.Port)
|
||||
}
|
||||
return requested
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user